'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 = ''; // MMSI: empty string → unassign (sends "" so backend nulls the column). // Non-empty → must be digits-only; backend enforces uniqueness across aids. const mmsiVal = v('ef-mmsi'); if (mmsiVal && !/^\d{6,9}$/.test(mmsiVal)) { status.textContent = 'MMSI must be 6–9 digits, or blank to unassign.'; status.className = 'save-status err'; btn.disabled = false; btn.textContent = 'SAVE CHANGES'; return; } 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'), displacement_warn_m: flt('ef-drift-warn'), displacement_alarm_m: flt('ef-drift-alarm'), signal_loss_min: flt('ef-signal-loss') ? parseInt(v('ef-signal-loss')) : null, din3_function: v('ef-din3') || null, din4_function: v('ef-din4') || null, observaciones: v('ef-obs') || null, lamp_id: v('ef-lamp') || null, mmsi: mmsiVal, // "" unassigns; backend handles tipo_ais: mmsiVal ? 'ATON_21' : 'SIN_AIS', 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() ? `
` : ''; // AIS link — when set, AIS Type 21 with this MMSI updates the buoy's // lat_actual (ghost marker) and triggers drift/battery alerts using this // aid's per-buoy thresholds. const aisBlock = `
MMSI of the AtoN transponder on this buoy. Once set, the system listens for AIS Type 21 with this MMSI and shows the AIS-reported position as a transparent ghost over the nominal marker. Leave blank if the buoy has no AIS.
`; return `
Leave blank to use global settings. Override per buoy based on anchor chain length and operating area.
${aisBlock} ${nominalBlock}
`; } // ── 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;