cronjob de manejo de invitados listo

This commit is contained in:
2025-09-26 19:28:58 -06:00
parent 0d4b0cbf67
commit 7d7a845a75
6 changed files with 102 additions and 9 deletions

View File

@@ -325,7 +325,7 @@ function openAddUser() {
}
function openAddGuest() {
userFormMode.value = 'guest';
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false };
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false, etiquetas: ['invitado'] };
showUserForm.value = true;
}
function toggleSettingsMenu() { showSettingsMenu.value = !showSettingsMenu.value; }

View File

@@ -12,6 +12,9 @@
<button class="icon-btn" @click="$emit('remove', item)">Eliminar</button>
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
</div>
<div v-if="item.etiquetas && item.etiquetas.length" class="row" style="gap:6px; margin-top:6px;">
<span v-for="tag in item.etiquetas" :key="tag" class="chip">#{{ tag }}</span>
</div>
<div v-if="expanded && deviceList.length" style="margin-top:8px;">
<div class="grid">
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple @edit="$emit('editDevice', d)" />

View File

@@ -10,6 +10,12 @@
<input v-model="state.password" placeholder="contraseña" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
</label>
</div>
<div class="row">
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">Etiquetas (separadas por coma)</div>
<input v-model="etiquetasText" placeholder="invitado, vip" style="width:100%; background:transparent; border:none; outline:none; color:inherit;" />
</label>
</div>
<div class="row">
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">VLAN</div>
@@ -28,7 +34,7 @@
</template>
<script setup>
import { reactive, watch, computed } from 'vue';
import { reactive, watch, computed, ref } from 'vue';
const props = defineProps({
modelValue: { type: Object, default: () => ({ username:'', password:'', vlan:'', disabled:false }) },
@@ -38,13 +44,16 @@ const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
const state = reactive({ username:'', password:'', vlan:'', disabled:false });
const isEdit = computed(() => props.mode === 'edit');
const etiquetasText = ref('');
watch(() => props.modelValue, (v) => {
Object.assign(state, v || {});
const tags = Array.isArray(v?.etiquetas) ? v.etiquetas : [];
etiquetasText.value = tags.join(', ');
}, { immediate: true, deep: true });
function submit() {
emit('submit', { ...state });
const tags = etiquetasText.value.split(',').map(s => s.trim()).filter(Boolean).slice(0, 100);
emit('submit', { ...state, etiquetas: tags });
}
</script>

View File

@@ -1,5 +1,5 @@
import { createApp } from './src/app.js';
import { ensureSchema } from './src/services/db.js';
import { ensureSchema, disableGuestsFromYesterday } from './src/services/db.js';
const app = createApp();
const port = process.env.PORT || 3000;
@@ -13,3 +13,24 @@ try {
app.listen(port, () => {
console.log(`Node RADIUS REST API listening on :${port}`);
});
// Schedule daily guest disable at 4:00 AM America/Tegucigalpa (UTC-6 -> 10:00 UTC)
function scheduleGuestJob() {
const now = new Date();
const next = new Date(now);
next.setUTCHours(10, 0, 0, 0); // 10:00 UTC == 4:00 local (UTC-6)
if (next <= now) next.setUTCDate(next.getUTCDate() + 1);
const delay = next - now;
setTimeout(async function run() {
try {
const { count } = await disableGuestsFromYesterday();
if (count) console.log(`[guest-cron] Disabled ${count} invitado users from yesterday`);
} catch (e) {
console.error('[guest-cron] Error:', e?.message || e);
} finally {
scheduleGuestJob();
}
}, delay);
}
scheduleGuestJob();

View File

@@ -1,7 +1,7 @@
import { Router } from 'express';
import { VLAN_ID } from '../config/env.js';
import { clearRequests, getRecentRequests, registerSse } from '../sse.js';
import { deleteUserFromDb, readUsersFromDb, upsertUserToDb, pool } from '../services/db.js';
import { deleteUserFromDb, readUsersFromDb, upsertUserToDb, pool, disableGuestsFromYesterday } from '../services/db.js';
import { disconnectUserSessions } from '../services/radius.js';
const router = Router();
@@ -17,17 +17,31 @@ router.get('/users', async (_req, res) => {
}
});
router.get('/users/:username', async (req, res) => {
try {
const uname = String(req.params.username);
const items = await readUsersFromDb();
const found = items.find(u => u.username === uname);
if (!found) return res.status(404).json({ ok: false, error: 'not_found' });
res.json({ ok: true, item: found });
} catch (e) {
console.error('GET /api/users/:username error:', e?.message || e);
res.status(500).json({ ok: false, error: 'db_error' });
}
});
router.post('/users', async (req, res) => {
const { username, password, vlan, disabled } = req.body || {};
const { username, password, vlan, disabled, etiquetas } = req.body || {};
if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' });
const user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled };
if (Array.isArray(etiquetas)) user.etiquetas = etiquetas.map(String).slice(0, 100);
await upsertUserToDb(user);
res.json({ ok: true });
});
router.patch('/users/:username', async (req, res) => {
const uname = String(req.params.username);
const { password, vlan, disabled } = req.body || {};
const { password, vlan, disabled, etiquetas } = req.body || {};
const current = (await readUsersFromDb()).find(u => u.username === uname);
if (!current) return res.status(404).json({ ok: false, error: 'not_found' });
const next = {
@@ -36,6 +50,7 @@ router.patch('/users/:username', async (req, res) => {
vlan: vlan !== undefined ? String(vlan) : current.vlan,
disabled: disabled !== undefined ? !!disabled : current.disabled,
};
if (etiquetas !== undefined) next.etiquetas = Array.isArray(etiquetas) ? etiquetas.map(String).slice(0, 100) : current.etiquetas;
await upsertUserToDb(next);
if (disabled === true) {
disconnectUserSessions(uname).catch(err => console.error('CoA disconnect error:', err));
@@ -202,4 +217,15 @@ router.post('/devices/:id/disconnect', async (req, res) => {
}
});
// Manual trigger: disable invited users from yesterday (America/Tegucigalpa day)
router.post('/guests/disable-yesterday', async (_req, res) => {
try {
const { count } = await disableGuestsFromYesterday();
res.json({ ok: true, count });
} catch (e) {
console.error('POST /api/guests/disable-yesterday error:', e?.message || e);
res.status(500).json({ ok: false, error: 'db_error' });
}
});
export default router;

View File

@@ -176,7 +176,7 @@ export async function readUsersFromDb() {
}
export async function upsertUserToDb(user) {
const { username, password, vlan, disabled } = user;
const { username, password, vlan, disabled, etiquetas } = user;
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -185,6 +185,9 @@ export async function upsertUserToDb(user) {
'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO NOTHING',
[username]
);
if (Array.isArray(etiquetas)) {
await client.query('UPDATE users SET etiquetas = $2, updated_at = NOW() WHERE username = $1', [username, etiquetas.map(String).slice(0, 100)]);
}
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)",
@@ -234,6 +237,37 @@ export async function upsertUserToDb(user) {
}
}
export async function disableGuestsFromYesterday() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(`
WITH tz AS (
SELECT (NOW() AT TIME ZONE 'America/Tegucigalpa')::date AS today
)
SELECT u.username
FROM users u, tz
WHERE 'invitado' = ANY(u.etiquetas)
AND u.habilitado_since IS NOT NULL
AND (u.habilitado_since AT TIME ZONE 'America/Tegucigalpa')::date = (tz.today - INTERVAL '1 day')::date
`);
const usernames = rows.map(r => r.username);
if (usernames.length === 0) { await client.query('COMMIT'); return { count: 0 }; }
await client.query("DELETE FROM radcheck WHERE attribute = 'Auth-Type' AND username = ANY($1)", [usernames]);
const values = usernames.map(u => `('${u}','Auth-Type',':=','Reject')`).join(',');
await client.query(`INSERT INTO radcheck (username, attribute, op, value) VALUES ${values}`);
await client.query('UPDATE users SET updated_at = NOW() WHERE username = ANY($1)', [usernames]);
await client.query('COMMIT');
return { count: usernames.length };
} catch (e) {
await client.query('ROLLBACK');
console.error('disableGuestsFromYesterday error:', e?.message || e);
throw e;
} finally {
client.release();
}
}
export async function deleteUserFromDb(username) {
const client = await pool.connect();
try {