540 lines
20 KiB
JavaScript
540 lines
20 KiB
JavaScript
'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;
|