cronjob de manejo de invitados listo
This commit is contained in:
@@ -325,7 +325,7 @@ function openAddUser() {
|
|||||||
}
|
}
|
||||||
function openAddGuest() {
|
function openAddGuest() {
|
||||||
userFormMode.value = 'guest';
|
userFormMode.value = 'guest';
|
||||||
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false };
|
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false, etiquetas: ['invitado'] };
|
||||||
showUserForm.value = true;
|
showUserForm.value = true;
|
||||||
}
|
}
|
||||||
function toggleSettingsMenu() { showSettingsMenu.value = !showSettingsMenu.value; }
|
function toggleSettingsMenu() { showSettingsMenu.value = !showSettingsMenu.value; }
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<button class="icon-btn" @click="$emit('remove', item)">Eliminar</button>
|
<button class="icon-btn" @click="$emit('remove', item)">Eliminar</button>
|
||||||
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
||||||
</div>
|
</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 v-if="expanded && deviceList.length" style="margin-top:8px;">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple @edit="$emit('editDevice', d)" />
|
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple @edit="$emit('editDevice', d)" />
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
<input v-model="state.password" placeholder="contraseña" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
|
<input v-model="state.password" placeholder="contraseña" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="row">
|
||||||
<label class="toggle" style="flex:1;">
|
<label class="toggle" style="flex:1;">
|
||||||
<div class="muted" style="font-size:12px;">VLAN</div>
|
<div class="muted" style="font-size:12px;">VLAN</div>
|
||||||
@@ -28,7 +34,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, watch, computed } from 'vue';
|
import { reactive, watch, computed, ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Object, default: () => ({ username:'', password:'', vlan:'', disabled:false }) },
|
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 state = reactive({ username:'', password:'', vlan:'', disabled:false });
|
||||||
const isEdit = computed(() => props.mode === 'edit');
|
const isEdit = computed(() => props.mode === 'edit');
|
||||||
|
const etiquetasText = ref('');
|
||||||
|
|
||||||
watch(() => props.modelValue, (v) => {
|
watch(() => props.modelValue, (v) => {
|
||||||
Object.assign(state, v || {});
|
Object.assign(state, v || {});
|
||||||
|
const tags = Array.isArray(v?.etiquetas) ? v.etiquetas : [];
|
||||||
|
etiquetasText.value = tags.join(', ');
|
||||||
}, { immediate: true, deep: true });
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
function submit() {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createApp } from './src/app.js';
|
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 app = createApp();
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
@@ -13,3 +13,24 @@ try {
|
|||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Node RADIUS REST API listening on :${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();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { VLAN_ID } from '../config/env.js';
|
import { VLAN_ID } from '../config/env.js';
|
||||||
import { clearRequests, getRecentRequests, registerSse } from '../sse.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';
|
import { disconnectUserSessions } from '../services/radius.js';
|
||||||
|
|
||||||
const router = Router();
|
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) => {
|
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' });
|
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 };
|
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);
|
await upsertUserToDb(user);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/users/:username', async (req, res) => {
|
router.patch('/users/:username', async (req, res) => {
|
||||||
const uname = String(req.params.username);
|
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);
|
const current = (await readUsersFromDb()).find(u => u.username === uname);
|
||||||
if (!current) return res.status(404).json({ ok: false, error: 'not_found' });
|
if (!current) return res.status(404).json({ ok: false, error: 'not_found' });
|
||||||
const next = {
|
const next = {
|
||||||
@@ -36,6 +50,7 @@ router.patch('/users/:username', async (req, res) => {
|
|||||||
vlan: vlan !== undefined ? String(vlan) : current.vlan,
|
vlan: vlan !== undefined ? String(vlan) : current.vlan,
|
||||||
disabled: disabled !== undefined ? !!disabled : current.disabled,
|
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);
|
await upsertUserToDb(next);
|
||||||
if (disabled === true) {
|
if (disabled === true) {
|
||||||
disconnectUserSessions(uname).catch(err => console.error('CoA disconnect error:', err));
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export async function readUsersFromDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertUserToDb(user) {
|
export async function upsertUserToDb(user) {
|
||||||
const { username, password, vlan, disabled } = user;
|
const { username, password, vlan, disabled, etiquetas } = user;
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
@@ -185,6 +185,9 @@ export async function upsertUserToDb(user) {
|
|||||||
'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO NOTHING',
|
'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO NOTHING',
|
||||||
[username]
|
[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("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Cleartext-Password'", [username]);
|
||||||
await client.query(
|
await client.query(
|
||||||
"INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Cleartext-Password',':=',$2)",
|
"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) {
|
export async function deleteUserFromDb(username) {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user