Files
AidsMonitoring/frontend/js/auth.js
T

540 lines
20 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 = ''; // relative — works from any IP (localhost, Tailscale, LAN)
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();
window._clearPortConstraints?.();
_portCache = null;
// Return to login screen
document.getElementById('app').classList.add('hidden');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('ls-user').value = '';
document.getElementById('ls-pass').value = '';
document.getElementById('ls-error').classList.add('hidden');
setTimeout(() => document.getElementById('ls-user').focus(), 100);
}
};
}
},
};
// ── 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();
Auth.save({ token: data.access_token, username: data.username,
nombre: data.nombre, role: data.role });
_hideOverlay();
afterLogin();
_fetchAndApplyPort(data.access_token, data.role); // update constraints if user changed
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');
}
// ── Port navigation + constraints ─────────────────────────────────────────────
// Cached port result — re-applied after the app container becomes visible.
let _portCache = null; // { lon, lat, zoom, port }
// Called right after #app becomes visible.
// OL initialized with display:none → size=0×0 → map renders half-screen and
// ignores setCenter. Fix: updateSize() tells OL the real container size,
// then re-apply port position so the map lands at the correct place.
function _showMap() {
requestAnimationFrame(() => {
window._mapUpdateSize?.(); // fix half-screen rendering
requestAnimationFrame(() => {
_applyPortToMap(); // re-apply position after resize
});
});
}
function _applyPortToMap() {
if (!_portCache) return;
const { lon, lat, zoom, port } = _portCache;
// Step 1: clear any previous constraints so navigation is free
window._clearPortConstraints?.();
// Step 2: fly to port (same as port search)
window.flyToCoords?.(lon, lat, zoom);
// Step 3: after animation lands, lock zoom-out and extent
if (port) {
setTimeout(() => window._setPortConstraints?.(port), 1400); // 1200ms anim + margin
}
}
// Fetch /org/me/company, cache result, apply to map.
// Call this BEFORE showing the app, then re-apply after show (OL resize fix).
async function _fetchAndApplyPort(token, role) {
try {
const res = await fetch(`${API}/org/me/company`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const me = await res.json();
if (!me.port || me.port.center_lat == null || me.port.center_lon == null) return;
const isClient = (role === 'USER' || role === 'CLIENT_ADMIN');
_portCache = {
lon: me.port.center_lon,
lat: me.port.center_lat,
zoom: me.port.default_zoom || 12,
port: isClient ? me.port : null,
};
_applyPortToMap();
} catch (_) { /* non-fatal */ }
}
// ── 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 cached token. If valid, configure map THEN show app.
Auth.load();
if (Auth.isLoggedIn()) {
try {
const r = await fetch(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${Auth.token()}` },
});
if (r.ok) {
try {
await _fetchAndApplyPort(Auth.token(), Auth.session.role);
app.classList.remove('hidden');
window._mapUpdateSize?.();
_applyPortToMap();
} catch (e) {
console.warn('[auth] map positioning error:', e);
app.classList.remove('hidden');
}
screen.classList.add('hidden');
afterLogin();
return;
}
Auth.clear();
} catch { Auth.clear(); }
}
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 });
// Map positioning is separate — errors here must NOT show credential message
try {
await _fetchAndApplyPort(data.access_token, data.role);
app.classList.remove('hidden'); // show app BEHIND login screen (z-index:9999)
window._mapUpdateSize?.(); // OL measures real container size
_applyPortToMap(); // position map at correct port
} catch (e) {
console.warn('[auth] map positioning error:', e);
app.classList.remove('hidden'); // show app anyway at default position
}
screen.classList.add('hidden'); // reveal map
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();
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;