Initial commit

This commit is contained in:
2025-11-28 07:49:17 -03:00
commit d84803efc9
12 changed files with 2074 additions and 0 deletions

6
.env.example Normal file
View File

@@ -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

15
Dockerfile Normal file
View File

@@ -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

14
api/Dockerfile Normal file
View File

@@ -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"]

17
api/package.json Normal file
View File

@@ -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"
}
}

412
api/src/server.js Normal file
View File

@@ -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);
});

BIN
assets/Logo tema Claro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
assets/Logo tema Escuro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

1021
assets/app.js Normal file

File diff suppressed because it is too large Load Diff

159
assets/style.css Normal file
View File

@@ -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; }
}

55
docker-compose.yml Normal file
View File

@@ -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:

347
index.html Normal file
View File

@@ -0,0 +1,347 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dashboard Telseg</title>
<!-- Favicon em branco para não exibir logo na aba -->
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" />
<link rel="stylesheet" href="assets/style.css" />
</head>
<body>
<header class="app-header">
<div class="brand">
<img src="assets/Logo tema Claro.png" class="logo-img light" alt="Logo" />
<img src="assets/Logo tema Escuro.png" class="logo-img dark" alt="" />
<h1>Dashboard Telseg</h1>
</div>
<nav class="top-actions">
<button id="themeToggle" title="Alternar tema" class="icon-btn" aria-label="Alternar tema">🌗</button>
<button id="exportBtn" class="btn" title="Exportar dados">Exportar</button>
<label for="importFile" class="btn" title="Importar dados">Importar</label>
<input id="importFile" type="file" accept="application/json" hidden />
</nav>
</header>
<div class="tabs">
<button class="tab active" data-tab="unidades">OC Discadora</button>
<button class="tab" data-tab="senhas">Senhas de Acesso</button>
<button class="tab" data-tab="notas">Notas</button>
<button class="tab" data-tab="usuarios" id="tab-users" class="hidden">Usuários</button>
</div>
<main class="views">
<!-- OC Discadora -->
<section id="view-unidades" class="view active" aria-labelledby="tab-unidades">
<div class="view-bar">
<div class="left">
<button id="addUnidadeBtn" class="btn primary">+ Adicionar OC</button>
</div>
<div class="right">
<input id="searchUnidades" class="input" placeholder="Buscar por cliente, OC ou IP" />
</div>
</div>
<div id="unidadesList" class="cards"></div>
</section>
<!-- Senhas -->
<section id="view-senhas" class="view" aria-labelledby="tab-senhas">
<div id="vaultLocked" class="vault-lock">
<h2>Cofre de Senhas</h2>
<p id="vaultStatus">Proteja suas senhas com uma senha mestra.</p>
<form id="setVaultForm" class="vault-form hidden">
<div class="grid">
<label>
<span>Senha Mestra</span>
<input type="password" id="vaultPass1" class="input" autocomplete="new-password" required />
</label>
<label>
<span>Confirmar Senha</span>
<input type="password" id="vaultPass2" class="input" autocomplete="new-password" required />
</label>
</div>
<button class="btn primary" type="submit">Definir senha mestra</button>
</form>
<form id="unlockVaultForm" class="vault-form hidden">
<label>
<span>Senha Mestra</span>
<input type="password" id="vaultUnlockPass" class="input" autocomplete="current-password" required />
</label>
<button class="btn primary" type="submit">Desbloquear</button>
</form>
</div>
<div id="vaultUnlocked" class="hidden">
<div class="view-bar">
<div class="left">
<button id="addCredBtn" class="btn primary">+ Nova Credencial</button>
</div>
<div class="right">
<input id="searchCreds" class="input" placeholder="Buscar por nome, usuário ou URL" />
<button id="lockVaultBtn" class="btn" title="Bloquear cofre">Bloquear</button>
</div>
</div>
<div id="credsList" class="cards"></div>
</div>
</section>
<!-- Notas -->
<section id="view-notas" class="view" aria-labelledby="tab-notas">
<div class="view-bar">
<div class="left">
<button id="addNoteBtn" class="btn primary">+ Nova Nota</button>
</div>
<div class="right">
<input id="searchNotes" class="input" placeholder="Buscar notas por título ou conteúdo" />
</div>
</div>
<!-- Duas colunas: Notas e Passo a Passo -->
<div id="notesColumns" class="notes-columns">
<div class="notes-col">
<h3 class="column-title">Notas</h3>
<div id="notesTextList" class="cards"></div>
</div>
<div class="notes-col">
<h3 class="column-title">Passo a Passo</h3>
<div id="notesStepsList" class="cards"></div>
</div>
</div>
</section>
<!-- Usuários (apenas Master) -->
<section id="view-usuarios" class="view" aria-labelledby="tab-usuarios">
<div class="view-bar">
<div class="left">
<h3 style="margin:0">Gerenciar Usuários</h3>
</div>
</div>
<div class="grid" style="margin-bottom:10px">
<form id="userForm" class="card" style="grid-column: 1 / -1">
<h4>Novo Usuário</h4>
<div class="grid">
<label><span>Usuário</span><input id="newUserName" class="input" required /></label>
<label><span>Senha</span><input id="newUserPass" class="input" type="password" required /></label>
<label><span>Perfil</span>
<select id="newUserRole" class="input">
<option value="user">Usuário</option>
<option value="master">Master</option>
</select>
</label>
</div>
<div class="k">Permissões</div>
<div class="grid">
<label><input type="checkbox" id="p_units_read"/> OC: ver</label>
<label><input type="checkbox" id="p_units_write"/> OC: editar/excluir</label>
<label><input type="checkbox" id="p_creds_read"/> Senhas: ver</label>
<label><input type="checkbox" id="p_creds_write"/> Senhas: editar/excluir</label>
<label><input type="checkbox" id="p_notes_read"/> Notas: ver</label>
<label><input type="checkbox" id="p_notes_write"/> Notas: editar/excluir</label>
</div>
<div class="modal-foot" style="padding:0">
<button type="submit" class="btn primary">Criar Usuário</button>
</div>
</form>
</div>
<div id="usersList" class="cards"></div>
</section>
</main>
<!-- Modal OC -->
<div id="unidadeModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="unidadeModalTitle">
<div class="modal-card">
<div class="modal-head">
<h3 id="unidadeModalTitle">OC Discadora</h3>
<button class="icon-btn" data-close-modal="unidadeModal" aria-label="Fechar"></button>
</div>
<form id="unidadeForm" class="modal-body">
<input type="hidden" id="unidadeId" />
<div class="grid">
<label>
<span>Nome do Cliente</span>
<input id="unidadeNome" class="input" required />
</label>
<label>
<span>Data de Criação</span>
<input id="unidadeData" type="date" class="input" required />
</label>
</div>
<label>
<span>Números das OCs (separe por vírgula ou linha)</span>
<textarea id="unidadeOCs" class="input" rows="3" placeholder="Ex.: 12345, 67890"></textarea>
</label>
<div class="grid">
<label>
<span>Link de Acesso</span>
<input id="unidadeLink" class="input" type="url" placeholder="https://..." />
</label>
<label>
<span>IP do Servidor</span>
<input id="unidadeIP" class="input" placeholder="0.0.0.0" />
</label>
</div>
<div class="grid">
<label>
<span>Login</span>
<input id="unidadeLogin" class="input" placeholder="usuário" />
</label>
<label>
<span>Senha</span>
<input id="unidadeSenha" class="input" type="password" placeholder="senha" />
</label>
</div>
<div class="modal-foot">
<button type="button" class="btn" data-close-modal="unidadeModal">Cancelar</button>
<button type="submit" class="btn primary">Salvar</button>
</div>
</form>
</div>
</div>
<!-- Modal Credencial -->
<div id="credModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="credModalTitle">
<div class="modal-card">
<div class="modal-head">
<h3 id="credModalTitle">Credencial</h3>
<button class="icon-btn" data-close-modal="credModal" aria-label="Fechar"></button>
</div>
<form id="credForm" class="modal-body">
<input type="hidden" id="credId" />
<div class="grid">
<label>
<span>Nome</span>
<input id="credNome" class="input" required />
</label>
<label>
<span>Usuário</span>
<input id="credUsuario" class="input" />
</label>
</div>
<div class="grid">
<label>
<span>Senha</span>
<input id="credSenha" type="password" class="input" />
</label>
<label>
<span>URL</span>
<input id="credUrl" class="input" type="url" placeholder="https://..." />
</label>
</div>
<label>
<span>Notas</span>
<textarea id="credNotas" class="input" rows="3"></textarea>
</label>
<div class="modal-foot">
<button type="button" class="btn" data-close-modal="credModal">Cancelar</button>
<button type="submit" class="btn primary">Salvar</button>
</div>
</form>
</div>
</div>
<!-- Modal Nota -->
<div id="noteModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="noteModalTitle">
<div class="modal-card">
<div class="modal-head">
<h3 id="noteModalTitle">Nota</h3>
<button class="icon-btn" data-close-modal="noteModal" aria-label="Fechar"></button>
</div>
<form id="noteForm" class="modal-body">
<input type="hidden" id="noteId" />
<label>
<span>Título</span>
<input id="noteTitle" class="input" placeholder="Ex.: Tarefas do cliente" required />
</label>
<div class="grid">
<label>
<span>Tipo da Nota</span>
<select id="noteType" class="input">
<option value="texto">Texto livre</option>
<option value="passos">Passo a passo</option>
</select>
</label>
</div>
<label>
<span>Conteúdo</span>
<textarea id="noteBody" class="input" rows="8" placeholder="Escreva suas notas aqui..."></textarea>
</label>
<div id="stepsBlock" class="hidden">
<span class="k">Passos</span>
<div class="row" style="gap:8px; align-items:stretch;">
<input id="newStepText" class="input" placeholder="Descreva o próximo passo" />
<button id="addStepBtn" type="button" class="btn">Adicionar passo</button>
</div>
<div id="stepsList" class="cards" style="grid-template-columns: 1fr; margin-top:8px;"></div>
</div>
<div class="modal-foot">
<button type="button" class="btn" data-close-modal="noteModal">Cancelar</button>
<button type="submit" class="btn primary">Salvar</button>
</div>
</form>
</div>
</div>
<!-- Modal Visualização de Nota -->
<div id="noteViewModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="noteViewTitle">
<div class="modal-card wide note-view">
<div class="modal-head">
<h3 id="noteViewTitle">Nota</h3>
<button class="icon-btn" data-close-modal="noteViewModal" aria-label="Fechar"></button>
</div>
<div class="modal-body" id="noteViewBody">
<!-- conteúdo renderizado via JS -->
</div>
<div class="modal-foot">
<button id="noteViewEdit" class="btn">Editar</button>
<button id="noteViewDelete" class="btn danger">Excluir</button>
</div>
</div>
</div>
<!-- Modal Visualização de Credencial -->
<div id="credViewModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="credViewTitle">
<div class="modal-card wide cred-view">
<div class="modal-head">
<h3 id="credViewTitle">Credencial</h3>
<button class="icon-btn" data-close-modal="credViewModal" aria-label="Fechar"></button>
</div>
<div class="modal-body" id="credViewBody">
<!-- conteúdo renderizado via JS -->
</div>
<div class="modal-foot">
<button id="credViewOpen" class="btn">Abrir</button>
<button id="credViewCopyUser" class="btn">Copiar usuário</button>
<button id="credViewCopyPass" class="btn">Copiar senha</button>
<button id="credViewReveal" class="btn">Revelar</button>
<button id="credViewEdit" class="btn">Editar</button>
<button id="credViewDelete" class="btn danger">Excluir</button>
</div>
</div>
</div>
<footer class="app-footer">
<small id="storageInfo">Detectando modo de armazenamento…</small>
</footer>
<!-- Autenticação -->
<div id="authGate" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="loginTitle">
<div class="modal-card" style="max-width:420px">
<div class="modal-head">
<h3 id="loginTitle">Entrar</h3>
</div>
<form id="loginForm" class="modal-body">
<label><span>Usuário</span><input id="loginUser" class="input" required /></label>
<label><span>Senha</span><input id="loginPass" type="password" class="input" required /></label>
<div class="modal-foot">
<button type="submit" class="btn primary">Entrar</button>
</div>
<div class="k">Dica: no primeiro acesso via Docker use as credenciais definidas nas variáveis ADMIN_USER e ADMIN_PASSWORD.</div>
</form>
</div>
</div>
<script src="assets/app.js"></script>
</body>
</html>

28
nginx.conf Normal file
View File

@@ -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";
}
}