467 lines
17 KiB
JavaScript
467 lines
17 KiB
JavaScript
'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;
|