diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8f17aa9..6fdd71d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,9 +12,14 @@ - + @@ -55,24 +60,10 @@
-<<<<<<< HEAD
Cargando usuarios…
-======= -
- - - - - -
-
Cargando usuarios…
-
- ->>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
@@ -103,14 +94,15 @@
-<<<<<<< HEAD -======= ->>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722 + + + + diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue index 025172d..13f9586 100644 --- a/frontend/src/components/Modal.vue +++ b/frontend/src/components/Modal.vue @@ -1,6 +1,6 @@ - diff --git a/frontend/src/components/RawDbViewer.vue b/frontend/src/components/RawDbViewer.vue new file mode 100644 index 0000000..f35e8f2 --- /dev/null +++ b/frontend/src/components/RawDbViewer.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend/src/components/UserCard.js b/frontend/src/components/UserCard.js index a04bcf7..baf6c99 100644 --- a/frontend/src/components/UserCard.js +++ b/frontend/src/components/UserCard.js @@ -5,28 +5,18 @@ const html = htm.bind(h); export default defineComponent({ name: 'UserCard', props: { item: { type: Object, required: true }, mode: { type: String, default: 'user' } }, -<<<<<<< HEAD emits: ['toggleDisable', 'remove', 'edit'], setup(props, { emit }) { function toggle() { emit('toggleDisable', props.item); } function remove() { emit('remove', props.item); } function edit() { emit('edit', props.item); } -======= - emits: ['toggleDisable', 'remove'], - setup(props, { emit }) { - function toggle() { emit('toggleDisable', props.item); } - function remove() { emit('remove', props.item); } ->>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722 return () => html`
${props.mode === 'user' ? props.item.username : (props.item.device || props.item.username)} VLAN ${props.item.vlan} ${props.item.disabled ? html`deshabilitado` : html`activo`} -<<<<<<< HEAD -======= ->>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
@@ -34,7 +24,3 @@ export default defineComponent({
`; } }); -<<<<<<< HEAD -======= - ->>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722 diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f6ae7a8..5de82b8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -80,6 +80,10 @@ a { color: inherit; } backdrop-filter: blur(var(--glass-blur)); } .icon-btn:hover { transform: translateY(-1px); background: rgba(var(--card)); } .icon { width: 16px; height: 16px; opacity: .9; } +.dropdown { position: relative; } +.menu { position: absolute; right: 0; top: calc(100% + 6px); background: rgba(var(--card)); border: 1px solid #ffcfe4; border-radius: 10px; padding: 6px; backdrop-filter: blur(var(--glass-blur)); min-width: 180px; box-shadow: 0 10px 24px rgba(0,0,0,.18); } +.menu button { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); } +.menu button:hover { transform: none; background: rgba(255,255,255,0.06); } /* Layout */ .shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; gap: 12px; padding: 12px; } @@ -117,6 +121,10 @@ a { color: inherit; } .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.35); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 20; animation: fadeIn .15s ease; } .modal { width: min(680px, 92vw); border-radius: 14px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); padding: 14px; box-shadow: 0 10px 32px rgba(0,0,0,.2); } +.modal.fullscreen { width: 96vw; max-width: 96vw; height: 92vh; max-height: 92vh; display: flex; flex-direction: column; } +.modal.fullscreen > .modal-header { position: sticky; top: 0; background: rgba(var(--card)); z-index: 1; } +.modal.fullscreen > .modal-footer { position: sticky; bottom: 0; background: rgba(var(--card)); z-index: 1; } +.modal.fullscreen > div:nth-child(2) { flex: 1 1 auto; overflow: auto; } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } diff --git a/node-api/index.js b/node-api/index.js index a15cb3f..c7fca86 100644 --- a/node-api/index.js +++ b/node-api/index.js @@ -1,8 +1,15 @@ import { createApp } from './src/app.js'; +import { ensureSchema } from './src/services/db.js'; const app = createApp(); const port = process.env.PORT || 3000; + +try { + await ensureSchema(); +} catch (e) { + console.error('Database schema ensure failed:', e?.message || e); +} + app.listen(port, () => { console.log(`Node RADIUS REST API listening on :${port}`); }); - diff --git a/node-api/src/routes/api.js b/node-api/src/routes/api.js index 97a3873..759efee 100644 --- a/node-api/src/routes/api.js +++ b/node-api/src/routes/api.js @@ -1,15 +1,20 @@ import { Router } from 'express'; import { VLAN_ID } from '../config/env.js'; import { clearRequests, getRecentRequests, registerSse } from '../sse.js'; -import { deleteUserFromDb, readUsersFromDb, upsertUserToDb } from '../services/db.js'; +import { deleteUserFromDb, readUsersFromDb, upsertUserToDb, pool } from '../services/db.js'; import { disconnectUserSessions } from '../services/radius.js'; const router = Router(); // Users router.get('/users', async (_req, res) => { - const items = await readUsersFromDb(); - res.json({ items }); + try { + const items = await readUsersFromDb(); + res.json({ items }); + } catch (e) { + console.error('GET /api/users error:', e?.message || e); + res.status(500).json({ items: [], ok: false, error: 'db_error' }); + } }); router.post('/users', async (req, res) => { @@ -89,4 +94,36 @@ router.get('/events', (req, res) => { registerSse(req, res, {}); }); +// Raw DB views (read-only) +router.get('/db/tables', async (_req, res) => { + try { + const { rows } = await pool.query( + "SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name ASC" + ); + res.json({ items: rows.map(r => r.table_name) }); + } catch (e) { + res.status(500).json({ ok: false, error: 'db_error' }); + } +}); + +router.get('/db/table/:name', async (req, res) => { + const t = String(req.params.name); + try { + // validate table name exists and is public + const { rows } = await pool.query( + "SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE' AND table_name = $1", + [t] + ); + if (rows.length === 0) return res.status(404).json({ ok: false, error: 'not_found' }); + const limit = Math.max(1, Math.min(1000, parseInt(String(req.query.limit || '100'), 10) || 100)); + const offset = Math.max(0, parseInt(String(req.query.offset || '0'), 10) || 0); + const totalRes = await pool.query(`SELECT COUNT(*)::int AS n FROM "${t}"`); + const total = totalRes.rows[0]?.n || 0; + const result = await pool.query(`SELECT * FROM "${t}" OFFSET $1 LIMIT $2`, [offset, limit]); + res.json({ ok: true, columns: result.fields.map(f => f.name), rows: result.rows, total, limit, offset }); + } catch (e) { + res.status(500).json({ ok: false, error: 'db_error' }); + } +}); + export default router; diff --git a/node-api/src/services/db.js b/node-api/src/services/db.js index 33ad868..87dd747 100644 --- a/node-api/src/services/db.js +++ b/node-api/src/services/db.js @@ -4,6 +4,70 @@ import { PGDATABASE, PGHOST, PGPASSWORD, PGPORT, PGUSER, SESSION_TIMEOUT, VLAN_I const { Pool } = pkgPg; export const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD }); +export async function ensureSchema() { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(` + CREATE TABLE IF NOT EXISTS vlans ( + id INTEGER PRIMARY KEY, + nombre TEXT NOT NULL, + descripcion TEXT + ); + `); + await client.query(` + CREATE TABLE IF NOT EXISTS dispositivos ( + id SERIAL PRIMARY KEY, + mac VARCHAR(32) UNIQUE NOT NULL, + nombre TEXT, + descripcion TEXT, + vendor TEXT, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ, + notas TEXT + ); + `); + await client.query(`CREATE INDEX IF NOT EXISTS dispositivos_mac_idx ON dispositivos (mac);`); + await client.query(` + CREATE TABLE IF NOT EXISTS users ( + username VARCHAR(64) PRIMARY KEY, + etiquetas TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + habilitado_since TIMESTAMPTZ, + dispositivos_utilizados INTEGER[] NOT NULL DEFAULT '{}', + dispositivos_conectados INTEGER[] NOT NULL DEFAULT '{}' + ); + `); + await client.query(` + CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; $$ LANGUAGE plpgsql; + `); + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated' + ) THEN + CREATE TRIGGER trg_users_updated + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + END$$; + `); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + console.error('ensureSchema error:', e?.message || e); + throw e; + } finally { + client.release(); + } +} + export async function readUsersFromDb() { const client = await pool.connect(); try { @@ -18,12 +82,30 @@ export async function readUsersFromDb() { SELECT rr.value FROM radreply rr WHERE rr.username = rc.username AND rr.attribute = 'Tunnel-Private-Group-Id' ORDER BY rr.id DESC LIMIT 1 - ), $1) AS vlan + ), $1) AS vlan, + u.etiquetas, + u.created_at, + u.updated_at, + u.habilitado_since, + u.dispositivos_utilizados, + u.dispositivos_conectados FROM radcheck rc + LEFT JOIN users u ON u.username = rc.username WHERE rc.attribute = 'Cleartext-Password' ORDER BY rc.username ASC`; const { rows } = await client.query(q, [String(VLAN_ID)]); - return rows.map(r => ({ username: r.username, password: r.password, vlan: String(r.vlan), disabled: !!r.disabled })); + return rows.map(r => ({ + username: r.username, + password: r.password, + vlan: String(r.vlan), + disabled: !!r.disabled, + etiquetas: r.etiquetas || [], + created_at: r.created_at || null, + updated_at: r.updated_at || null, + habilitado_since: r.habilitado_since || null, + dispositivos_utilizados: r.dispositivos_utilizados || [], + dispositivos_conectados: r.dispositivos_conectados || [], + })); } finally { client.release(); } @@ -34,6 +116,11 @@ export async function upsertUserToDb(user) { const client = await pool.connect(); try { await client.query('BEGIN'); + // Ensure metadata row exists + await client.query( + 'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO NOTHING', + [username] + ); await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Cleartext-Password'", [username]); await client.query( "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Cleartext-Password',':=',$2)", @@ -66,6 +153,14 @@ export async function upsertUserToDb(user) { [username, attr, String(val)] ); } + // Update metadata timestamps; set habilitado_since when enabled + await client.query( + `UPDATE users + SET updated_at = NOW(), + habilitado_since = CASE WHEN $2::boolean = FALSE THEN NOW() ELSE habilitado_since END + WHERE username = $1`, + [username, !!disabled] + ); await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); @@ -81,6 +176,7 @@ export async function deleteUserFromDb(username) { await client.query('BEGIN'); await client.query('DELETE FROM radcheck WHERE username = $1', [username]); await client.query('DELETE FROM radreply WHERE username = $1', [username]); + await client.query('DELETE FROM users WHERE username = $1', [username]); await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); @@ -89,4 +185,3 @@ export async function deleteUserFromDb(username) { client.release(); } } - diff --git a/postgres/init/02-users-devices.sql b/postgres/init/02-users-devices.sql new file mode 100644 index 0000000..98e011b --- /dev/null +++ b/postgres/init/02-users-devices.sql @@ -0,0 +1,59 @@ +-- Additional domain tables for VLANs, devices, and user metadata + +-- VLAN catalog (acts as enum of available VLANs) +CREATE TABLE IF NOT EXISTS vlans ( + id INTEGER PRIMARY KEY, + nombre TEXT NOT NULL, + descripcion TEXT +); + +-- Devices table: store individual device information +CREATE TABLE IF NOT EXISTS dispositivos ( + id SERIAL PRIMARY KEY, + mac VARCHAR(32) UNIQUE NOT NULL, + nombre TEXT, + descripcion TEXT, + vendor TEXT, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ, + notas TEXT +); +CREATE INDEX IF NOT EXISTS dispositivos_mac_idx ON dispositivos (mac); + +-- Users metadata table (separate from radcheck/radreply for FreeRADIUS) +CREATE TABLE IF NOT EXISTS users ( + username VARCHAR(64) PRIMARY KEY, + etiquetas TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + habilitado_since TIMESTAMPTZ, + dispositivos_utilizados INTEGER[] NOT NULL DEFAULT '{}', -- references dispositivos.id + dispositivos_conectados INTEGER[] NOT NULL DEFAULT '{}' -- references dispositivos.id +); + +-- updated_at trigger helper +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_proc WHERE proname = 'set_updated_at' + ) THEN + CREATE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated' + ) THEN + CREATE TRIGGER trg_users_updated + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END$$; +