Files
AidsMonitoring/frontend/js/auth.js
T

467 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const API = 'http://localhost:5503';
const SESSION_KEY = 'ams_session';
// ── Estado de sesión ────────────────────────────────────────────────────────
const Auth = {
session: null,
load() {
try {
const raw = localStorage.getItem(SESSION_KEY);
this.session = raw ? JSON.parse(raw) : null;
} catch { this.session = null; }
this._renderBadge();
},
save(data) {
this.session = data;
localStorage.setItem(SESSION_KEY, JSON.stringify(data));
this._renderBadge();
},
clear() {
this.session = null;
localStorage.removeItem(SESSION_KEY);
this._renderBadge();
},
isLoggedIn() { return !!this.session?.token; },
isAdmin() { return ['ADMIN','SUPERADMIN'].includes(this.session?.role); },
isSuperAdmin(){ return this.session?.role === 'SUPERADMIN'; },
token() { return this.session?.token || ''; },
_renderBadge() {
const el = document.getElementById('session-badge');
if (!el) return;
if (!this.session) {
el.innerHTML = `<span style="opacity:.5">NOT LOGGED IN</span>`;
el.title = 'Click to login';
el.onclick = () => Modal.openLogin(null);
} else {
const roleClass = {
SUPERADMIN: 'role-superadmin',
ADMIN: 'role-admin',
USER: 'role-user',
}[this.session.role] || 'role-user';
el.innerHTML = `
<span>${this.session.nombre}</span>
<span class="session-role ${roleClass}">${this.session.role}</span>
`;
el.title = 'Click to logout';
el.onclick = () => {
if (confirm(`Logout as ${this.session.nombre}?`)) Auth.clear();
};
}
},
};
// ── Modal controller ────────────────────────────────────────────────────────
const Modal = {
_pendingAid: null,
openLogin(aidData) {
this._pendingAid = aidData;
document.getElementById('login-user').value = '';
document.getElementById('login-pass').value = '';
document.getElementById('login-error').classList.add('hidden');
_showOverlay('modal-login');
setTimeout(() => document.getElementById('login-user').focus(), 100);
},
closeLogin() {
_hideOverlay();
this._pendingAid = null;
},
async submitLogin() {
const username = document.getElementById('login-user').value.trim();
const password = document.getElementById('login-pass').value;
const errEl = document.getElementById('login-error');
const btn = document.getElementById('btn-login-submit');
if (!username || !password) return;
btn.disabled = true;
btn.textContent = 'AUTHENTICATING...';
errEl.classList.add('hidden');
try {
const form = new URLSearchParams({ username, password });
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form,
});
if (!res.ok) throw new Error('invalid');
const data = await res.json();
if (!['ADMIN','SUPERADMIN'].includes(data.role)) {
errEl.textContent = 'Your account does not have edit permissions.';
errEl.classList.remove('hidden');
return;
}
Auth.save({ token: data.access_token, username: data.username,
nombre: data.nombre, role: data.role });
_hideOverlay();
if (this._pendingAid) {
this.openEdit(this._pendingAid);
this._pendingAid = null;
}
} catch {
errEl.textContent = 'Invalid credentials or insufficient permissions.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'LOGIN';
}
},
async openEdit(aidData) {
// Si no tiene sesión con permisos → abrir login primero
if (!Auth.isAdmin()) {
this.openLogin(aidData);
return;
}
document.getElementById('edit-aid-name').textContent = aidData.nombre;
document.getElementById('edit-save-status').textContent = '';
document.getElementById('edit-save-status').className = 'save-status';
const body = document.getElementById('modal-edit-body');
body.innerHTML = '<div style="padding:20px;color:var(--text-muted)">Loading…</div>';
_showOverlay('modal-edit');
// Refresh caches so the dropdowns show up-to-date contacts and lamps
try {
await Promise.all([
window.loadContacts ? window.loadContacts() : Promise.resolve(),
window.loadLamps ? window.loadLamps() : Promise.resolve(),
]);
} catch {}
body.innerHTML = buildEditForm(aidData);
document.getElementById('btn-edit-submit').onclick = () => this.submitEdit(aidData);
},
closeEdit() { _hideOverlay(); },
async submitEdit(aidData) {
const btn = document.getElementById('btn-edit-submit');
const status = document.getElementById('edit-save-status');
const motivo = document.getElementById('ef-motivo')?.value?.trim();
if (!motivo) {
status.textContent = 'Reason for change is required.';
status.className = 'save-status err';
return;
}
btn.disabled = true;
btn.textContent = 'SAVING...';
status.textContent = '';
const payload = {
puerto_responsable: v('ef-puerto') || null,
empresa_responsable: v('ef-empresa') || null,
caracteristica_luz: v('ef-luz') || null,
alcance_nm: flt('ef-alcance'),
radio_borneo_m: flt('ef-borneo'),
observaciones: v('ef-obs') || null,
lamp_id: v('ef-lamp') || null,
modificado_por: Auth.session.nombre,
motivo_cambio: motivo,
};
// Posición nominal solo si superadmin y se modificó
if (Auth.isSuperAdmin()) {
const lat = flt('ef-lat');
const lon = flt('ef-lon');
if (lat) payload.lat_nominal = lat;
if (lon) payload.lon_nominal = lon;
}
try {
const res = await fetch(`${API}/aids/${aidData.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Auth.token()}`,
},
body: JSON.stringify(payload),
});
if (res.status === 401) {
Auth.clear();
_hideOverlay();
this.openLogin(aidData);
return;
}
if (!res.ok) throw new Error(await res.text());
const updated = await res.json();
// Actualizar feature en el mapa
const feature = window.aidsSource?.getFeatureById(aidData.id);
if (feature) feature.setProperties({ ...feature.getProperties(), ...updated });
// Refrescar panel info
if (window.refreshInfoPanel) window.refreshInfoPanel({ ...aidData, ...updated });
status.textContent = 'Saved successfully.';
status.className = 'save-status ok';
setTimeout(() => _hideOverlay(), 1200);
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status err';
} finally {
btn.disabled = false;
btn.textContent = 'SAVE CHANGES';
}
},
};
// ── Form builder ─────────────────────────────────────────────────────────────
function buildEditForm(p) {
// Port options from PORT_AUTHORITY contacts (deduplicated by port_name)
const contacts = window._contactsCache || [];
const portsFromContacts = [...new Set(contacts
.filter(c => c.role === 'PORT_AUTHORITY' && c.port_name)
.map(c => c.port_name))];
// Keep the current value as an option even if no contact uses it yet
if (p.puerto_responsable && !portsFromContacts.includes(p.puerto_responsable)) {
portsFromContacts.push(p.puerto_responsable);
}
const pOpts = portsFromContacts.map(x =>
`<option value="${x}" ${p.puerto_responsable === x ? 'selected':''}>${x}</option>`).join('');
// Company options from OWNER contacts (deduplicated by company_name)
const companiesFromContacts = [...new Set(contacts
.filter(c => c.role === 'OWNER' && c.company_name)
.map(c => c.company_name))];
if (p.empresa_responsable && !companiesFromContacts.includes(p.empresa_responsable)) {
companiesFromContacts.push(p.empresa_responsable);
}
const cOpts = companiesFromContacts.map(x =>
`<option value="${x}" ${p.empresa_responsable === x ? 'selected':''}>${x}</option>`).join('');
// Lamp options from the lamp catalog
const lamps = window._lampCache || [];
const lOpts = lamps.map(l =>
`<option value="${l.id}" ${l.id === p.lamp_id ? 'selected':''}>${l.manufacturer} ${l.model} (${l.voltage_min}${l.voltage_max} V)</option>`).join('');
const nominalBlock = Auth.isSuperAdmin() ? `
<div class="modal-section-label">NOMINAL POSITION — SUPERADMIN ONLY</div>
<div class="field-row-modal">
<div class="form-field">
<label class="form-label">Latitude</label>
<input class="form-input" id="ef-lat" type="number" step="0.00001" value="${p.lat_nominal || ''}">
</div>
<div class="form-field">
<label class="form-label">Longitude</label>
<input class="form-input" id="ef-lon" type="number" step="0.00001" value="${p.lon_nominal || ''}">
</div>
</div>` : '';
return `
<div class="modal-section-label">GENERAL</div>
<div class="form-field">
<label class="form-label">Port Responsible
<span style="font-size:0.6rem;color:var(--text-muted)">(from contacts catalog)</span>
</label>
<select class="form-input-select" id="ef-puerto">
<option value="">-- Select --</option>
${pOpts}
</select>
</div>
<div class="form-field">
<label class="form-label">Company / Owner
<span style="font-size:0.6rem;color:var(--text-muted)">(from contacts catalog)</span>
</label>
<select class="form-input-select" id="ef-empresa">
<option value="">-- Select --</option>
${cOpts}
</select>
</div>
<div class="form-field">
<label class="form-label">Lamp Model
<span style="font-size:0.6rem;color:var(--text-muted)">(from lamp catalog — sets battery thresholds)</span>
</label>
<select class="form-input-select" id="ef-lamp">
<option value="">-- None --</option>
${lOpts}
</select>
</div>
<div class="modal-section-label">TECHNICAL</div>
<div class="field-row-modal">
<div class="form-field">
<label class="form-label">Light Characteristic</label>
<input class="form-input" id="ef-luz" type="text"
value="${p.caracteristica_luz || ''}" placeholder="e.g. Fl(2) R 10s">
</div>
<div class="form-field">
<label class="form-label">Range (nm)</label>
<input class="form-input" id="ef-alcance" type="number" step="0.5"
value="${p.alcance_nm || ''}">
</div>
</div>
<div class="form-field">
<label class="form-label">Swing Radius (m)</label>
<input class="form-input" id="ef-borneo" type="number" step="1"
value="${p.radio_borneo_m ?? 10}">
</div>
<div class="form-field">
<label class="form-label">Observations</label>
<textarea class="form-textarea" id="ef-obs"
style="height:64px">${p.observaciones || ''}</textarea>
</div>
${nominalBlock}
<div class="modal-section-label">AUDIT</div>
<div class="form-field">
<label class="form-label">Modified by</label>
<input class="form-input" value="${Auth.session?.nombre || ''}" disabled>
</div>
<div class="form-field">
<label class="form-label">Reason for change <span style="color:var(--red)">*</span></label>
<textarea class="form-textarea" id="ef-motivo"
style="height:56px" placeholder="Mandatory — describe the change..."></textarea>
</div>
`;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function v(id) { return document.getElementById(id)?.value?.trim() || ''; }
function flt(id) { const n = parseFloat(v(id)); return isNaN(n) ? null : n; }
function _showOverlay(modalId) {
document.getElementById('modal-overlay').classList.remove('hidden');
['modal-login','modal-edit'].forEach(id => {
document.getElementById(id).classList.toggle('hidden', id !== modalId);
});
}
function _hideOverlay() {
document.getElementById('modal-overlay').classList.add('hidden');
document.getElementById('modal-login').classList.add('hidden');
document.getElementById('modal-edit').classList.add('hidden');
}
// ── Login de inicio ───────────────────────────────────────────────────────────
async function initStartupLogin() {
const screen = document.getElementById('login-screen');
const app = document.getElementById('app');
const btn = document.getElementById('ls-submit');
const errEl = document.getElementById('ls-error');
// Validate the cached token against /auth/me before auto-entering. The
// backend may have restarted / token may have expired — in that case we
// force the user back to the login screen instead of showing a broken UI.
Auth.load();
if (Auth.isLoggedIn()) {
try {
const r = await fetch(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${Auth.token()}` },
});
if (r.ok) {
screen.classList.add('hidden');
app.classList.remove('hidden');
afterLogin();
return;
}
// 401 / network error → drop the stale session and show login
Auth.clear();
} catch {
Auth.clear();
}
}
// Make sure login screen is visible if we got here
screen.classList.remove('hidden');
app.classList.add('hidden');
async function doLogin() {
const username = document.getElementById('ls-user').value.trim();
const password = document.getElementById('ls-pass').value;
if (!username || !password) return;
btn.disabled = true;
btn.textContent = 'AUTHENTICATING...';
errEl.classList.add('hidden');
try {
const form = new URLSearchParams({ username, password });
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form,
});
if (!res.ok) throw new Error('invalid');
const data = await res.json();
Auth.save({ token: data.access_token, username: data.username,
nombre: data.nombre, role: data.role });
screen.classList.add('hidden');
app.classList.remove('hidden');
afterLogin();
} catch {
errEl.textContent = 'Invalid username or password.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'LOGIN';
}
}
btn.addEventListener('click', doLogin);
document.getElementById('ls-pass').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doLogin();
});
}
function afterLogin() {
if (window.PortSearch) window.PortSearch.init();
Auth._renderBadge();
// Sincronizar barra de estado inferior con usuario conectado
const sbUser = document.getElementById('sb-user');
if (sbUser && Auth.session) {
sbUser.textContent = `${Auth.session.nombre} · ${Auth.session.role}`;
}
}
// ── Event listeners ──────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initStartupLogin();
document.getElementById('btn-login-submit')?.addEventListener('click', () => Modal.submitLogin());
document.getElementById('btn-login-cancel')?.addEventListener('click', () => Modal.closeLogin());
document.getElementById('btn-close-login')?.addEventListener('click', () => Modal.closeLogin());
document.getElementById('btn-close-edit')?.addEventListener('click', () => Modal.closeEdit());
document.getElementById('btn-edit-cancel')?.addEventListener('click', () => Modal.closeEdit());
document.getElementById('login-pass')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') Modal.submitLogin();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') _hideOverlay();
});
// (Click on the backdrop intentionally does NOT close modals — user must
// use the X or CLOSE button. Avoids losing unsaved Settings inputs.)
});
window.Auth = Auth;
window.Modal = Modal;