'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 = `NOT LOGGED IN`;
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 = `
${this.session.nombre}
${this.session.role}
`;
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 = '
Loading…
';
_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 =>
``).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 =>
``).join('');
// Lamp options from the lamp catalog
const lamps = window._lampCache || [];
const lOpts = lamps.map(l =>
``).join('');
const nominalBlock = Auth.isSuperAdmin() ? `
NOMINAL POSITION — SUPERADMIN ONLY
` : '';
return `
GENERAL
TECHNICAL
${nominalBlock}
AUDIT
`;
}
// ── 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;