Initial commit
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal 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
15
Dockerfile
Normal 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
14
api/Dockerfile
Normal 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
17
api/package.json
Normal 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
412
api/src/server.js
Normal 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
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
BIN
assets/Logo tema Escuro.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
1021
assets/app.js
Normal file
1021
assets/app.js
Normal file
File diff suppressed because it is too large
Load Diff
159
assets/style.css
Normal file
159
assets/style.css
Normal 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
55
docker-compose.yml
Normal 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
347
index.html
Normal 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
28
nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user