Fix stale district + offline-safe logout; GPS UTM format; touch-cursor gating; SW v12

Session / district correctness
- public/index.php: add a /?logout=1 endpoint that destroys the PWA's own PHP
  session (session_destroy + expire PHPSESSID + clear sso_auth_token, then
  redirect to the SSO portal). Logout previously cleared only the SSO cookie,
  leaving the PHPSESSID session — and its frozen district_id — intact, which is
  why a reassigned user kept loading the old district across logout/login.
- SSO token is validated once per session, at login (unchanged first-login
  logic). A district transfer is now picked up on the next logout→login, which
  is correct precisely because logout finally tears the session down. No
  periodic SSO polling.
- main.js: the menu Logout button routes through /?logout=1 and wipes
  district-scoped local caches first. Logout is blocked while offline — a
  session can only be created online, so an offline logout would strand the
  user with no way back in (and would not actually reach the server).
- main.js: enforceDistrictConsistency() clears district-scoped caches when the
  session district changes between loads; the district boundary is cached under
  a per-district key (district_boundary_<id>) so one district's geometry can
  never be served for another.

GPS coordinate format
- New "GPS Coordinate Format" setting (Lat/Lon · UTM · Both) in the Settings
  panel; the navbar read-out renders the chosen format and repaints the current
  fix immediately on change. Self-contained WGS84→UTM converter in
  geo-utils.js, verified against an independent Redfearn-series implementation.

ol-ext touch cursor
- MapView gates the TouchCursor to genuine touch-only devices via matchMedia
  (any-pointer: fine / any-hover: hover); hybrid touchscreen laptops keep the
  normal cursor. Reactive to pointer-capability changes.

- Service worker v11 → v12 (new shell). docs/SSO_Session_Refresh_Proposal.md
  documents the implemented approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-06-25 11:58:36 +00:00
parent 3479b00d83
commit d8ddbbc910
13 changed files with 725 additions and 139 deletions

File diff suppressed because one or more lines are too long

1
dist/assets/index-BTRQkY7z.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

34
dist/index.html vendored
View File

@ -1054,6 +1054,8 @@
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; } .gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; } .gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
.gps-coords { font-weight: 600; } .gps-coords { font-weight: 600; }
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
.gps-meta { display: flex; gap: 5px; opacity: 0.85; } .gps-meta { display: flex; gap: 5px; opacity: 0.85; }
/* Active fix: tint green-ish; colour-coded further from JS via quality class */ /* Active fix: tint green-ish; colour-coded further from JS via quality class */
.gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); } .gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
@ -1069,6 +1071,7 @@
} }
@media (max-width: 380px) { @media (max-width: 380px) {
.gps-readout .gps-meta { display: none; } .gps-readout .gps-meta { display: none; }
.gps-readout .gps-utm { display: none; }
} }
/* ol-ext SearchNominatim styling */ /* ol-ext SearchNominatim styling */
@ -1598,7 +1601,7 @@
} }
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-DRlPLJxg.js"></script> <script type="module" crossorigin src="/assets/index-BTRQkY7z.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js"> <link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js"> <link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js"> <link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
@ -1624,6 +1627,7 @@
<i class="bi bi-broadcast" aria-hidden="true"></i> <i class="bi bi-broadcast" aria-hidden="true"></i>
<span class="gps-readout-body"> <span class="gps-readout-body">
<span class="gps-coords" id="gps-coords">GPS off</span> <span class="gps-coords" id="gps-coords">GPS off</span>
<span class="gps-utm d-none" id="gps-utm"></span>
<span class="gps-meta"> <span class="gps-meta">
<span id="gps-accuracy"></span> <span id="gps-accuracy"></span>
<span class="gps-sep">·</span> <span class="gps-sep">·</span>
@ -1698,7 +1702,7 @@
<!-- Bottom Dock --> <!-- Bottom Dock -->
<div class="bottom-dock"> <div class="bottom-dock">
<button class="dock-btn active" type="button" id="dock-btn-add-location" title="Add Location Mode"> <button class="dock-btn" type="button" id="dock-btn-add-location" title="Add Location Mode">
<span>📍</span> <span>📍</span>
<span class="dock-btn-label">Add</span> <span class="dock-btn-label">Add</span>
</button> </button>
@ -2056,11 +2060,13 @@
<hr> <hr>
<!-- Sign-out (only visible when authenticated) --> <!-- Logout (only visible when authenticated) — routes through the
server logout endpoint (/?logout=1) so the PWA's own PHP session
is destroyed, not just the SSO cookie. -->
<button type="button" id="menu-signout-btn" <button type="button" id="menu-signout-btn"
class="btn btn-outline-danger w-100 d-none" class="btn btn-outline-danger w-100 d-none"
style="font-weight:600;"> style="font-weight:600;">
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page <i class="bi bi-box-arrow-right me-2"></i>Logout
</button> </button>
<!-- Sign-in prompt (only visible when NOT authenticated) --> <!-- Sign-in prompt (only visible when NOT authenticated) -->
@ -2220,6 +2226,26 @@
</div> </div>
</div> </div>
</div> </div>
<!-- GPS Coordinate Format -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">GPS Coordinate Format</h6>
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
<option value="latlon">Lat / Lon</option>
<option value="utm">UTM</option>
<option value="both">Both</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map --> <!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4"> <div class="col-12 col-md-6 col-lg-4">
<div class="card"> <div class="card">

36
dist/index.php vendored
View File

@ -24,8 +24,42 @@
session_start(); session_start();
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session // Logout — end the PWA's OWN session
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
// the central SSO portal. Logging out of the SSO portal clears `sso_auth_token`
// but leaves this PHPSESSID session intact, so the previous user's fields
// (incl. district_id) persist. This endpoint is what actually destroys the PWA
// session. Triggered by `/?logout=1` from the in-app menu.
if (isset($_GET['logout'])) {
$_SESSION = [];
// Expire the session cookie itself.
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
// Clear the shared SSO cookie across all *.lupmis4luspa.org subdomains.
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
// Bounce to the central LUSPA portal to complete SSO logout.
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate once per session, at login
// ────────────────────────────────────────────────────────────────────────────
// A user's district changes only when they are transferred — a rare event — so
// the validated fields (district_id, region_id, name, …) are valid for the
// whole session. We therefore validate the SSO token only when there is no
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
//
// A transfer is picked up the next time the user logs out and back in: the
// Logout endpoint above destroys the PHP session, so the subsequent login has
// no session and re-validates, fetching the new district_id. This is correct
// precisely because logout now tears the session down (previously it only
// cleared the SSO cookie, which left the stale district pinned).
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) { if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token']; $plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken); $validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);

10
dist/sw.js vendored
View File

@ -45,7 +45,15 @@
// tagging, geometry-edit + delete persistence to the staging tables, // tagging, geometry-edit + delete persistence to the staging tables,
// sample values + Unicode-bold field names in the mapping dropdown). // sample values + Unicode-bold field names in the mapping dropdown).
// New hashed bundle + updated embed.php shell. // New hashed bundle + updated embed.php shell.
const CACHE_VERSION = 'v11'; // v12: Session/district correctness — a real /?logout=1 endpoint that destroys
// the PWA's own PHP session (logout previously only cleared the SSO
// cookie, leaving the district_id pinned). SSO is validated once per
// session at login; a transfer is picked up on the next logout→login.
// Client-side stale-district guard (wipes district-scoped caches when the
// session district changes) + district-keyed boundary cache; GPS read-out
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
// devices. New hashed bundle + updated index.php shell.
const CACHE_VERSION = 'v12';
const SHELL_CACHE = `shell-${CACHE_VERSION}`; const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -0,0 +1,97 @@
# District-reassignment fix — implemented
**Status:** Implemented in `public/index.php`, `main.js`, `index.html`, `public/sw.js` (v12).
**Approach chosen:** validate-once-per-session + destroy-session-on-logout (no periodic TTL polling).
---
## 1. The bug
When an administrator reassigned a user to a different district (e.g. **47 → 238**),
the PWA kept navigating to the old district even after logout, cache clear, and
re-login.
Two compounding causes:
1. **`public/index.php` validated the SSO token only when no PHP session
existed.** Once `$_SESSION['user_id']` was set, the user's fields
(`district_id`, …) were frozen for the life of that PHP session.
2. **Nothing ever destroyed the PWA's own PHP session.** The PWA
(`pwa.lupmis4luspa.org`) keeps its own `PHPSESSID` session, independent of
the central SSO portal. "Logout" cleared only the `sso_auth_token` cookie;
the `PHPSESSID` session (holding district 47) survived. Browser "clear cache"
does not delete cookies, so the stale `PHPSESSID` kept being reused — and
because a session still existed, SSO was never re-validated.
Net effect: district 47 stayed pinned until the `PHPSESSID` session was
physically destroyed (which nothing did).
## 2. The fix (as implemented)
A district changes only on transfer — a rare event — so the validated session
is correct for the entire session. We therefore do **not** poll SSO on a timer.
Instead:
- **Validate once per session, at login** — unchanged first-login validation in
`index.php` (`if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token']))`).
- **Destroy the session on logout** — a new `/?logout=1` endpoint in
`index.php` runs `session_destroy()`, expires the `PHPSESSID` cookie, clears
`sso_auth_token`, and redirects to the LUSPA portal.
A transferred user logs out → the PHP session is destroyed → on the next login
there is no session, so SSO is re-validated and the **new** `district_id` is
fetched. No periodic SSO calls; the session stays valid the whole time the user
is working.
### Logout endpoint (`public/index.php`, immediately after `session_start()`)
```php
if (isset($_GET['logout'])) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
```
The SSO validation block below it is the original first-login-only logic.
## 3. Client-side changes
- **Logout button** (`index.html` / `main.js`) — the menu's "Logout" button now
routes through `/?logout=1` instead of only clearing the cookie, and wipes the
device's district-scoped SQLite caches first.
- **Stale-district guard** (`main.js` `enforceDistrictConsistency()`) — on load,
if the session's `district_id` differs from the last-seen value in
`localStorage`, all district-scoped caches (boundary, UPN grid, parcels,
zones, roads) are cleared so the map repopulates for the new district.
- **District-keyed boundary cache** — the boundary is cached as
`district_boundary_<id>`, so one district's geometry can never be served for
another.
## 4. Why not a periodic TTL
An earlier draft re-validated SSO on a 5-minute TTL. Rejected because:
- District changes are rare (transfers only), so polling SSO on every load
wastes a blocking `curl` on the page's critical path for no benefit almost
all of the time.
- The logout→login cycle is the natural, explicit moment a transfer takes
effect, and it is now correct because logout destroys the session.
## 5. Operational notes
- **Existing stale sessions:** a transferred user who is still on an old session
must **log out and log back in** (or have their `PHPSESSID` cleared) once after
deploy to pick up the new district. After that the model is self-correcting.
- **Assumes the SSO `validate` endpoint returns the new district** — confirmed
via an incognito test (fresh session injected the correct `district_id: 238`).
- **Edge case:** switching to a different SSO user *without* logging out of the
PWA first would reuse the old PHP session. The Logout button is the supported
path; this matches the original single-user-per-device assumption.

View File

@ -1054,6 +1054,8 @@
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; } .gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; } .gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
.gps-coords { font-weight: 600; } .gps-coords { font-weight: 600; }
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
.gps-meta { display: flex; gap: 5px; opacity: 0.85; } .gps-meta { display: flex; gap: 5px; opacity: 0.85; }
/* Active fix: tint green-ish; colour-coded further from JS via quality class */ /* Active fix: tint green-ish; colour-coded further from JS via quality class */
.gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); } .gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
@ -1069,6 +1071,7 @@
} }
@media (max-width: 380px) { @media (max-width: 380px) {
.gps-readout .gps-meta { display: none; } .gps-readout .gps-meta { display: none; }
.gps-readout .gps-utm { display: none; }
} }
/* ol-ext SearchNominatim styling */ /* ol-ext SearchNominatim styling */
@ -1615,6 +1618,7 @@
<i class="bi bi-broadcast" aria-hidden="true"></i> <i class="bi bi-broadcast" aria-hidden="true"></i>
<span class="gps-readout-body"> <span class="gps-readout-body">
<span class="gps-coords" id="gps-coords">GPS off</span> <span class="gps-coords" id="gps-coords">GPS off</span>
<span class="gps-utm d-none" id="gps-utm"></span>
<span class="gps-meta"> <span class="gps-meta">
<span id="gps-accuracy"></span> <span id="gps-accuracy"></span>
<span class="gps-sep">·</span> <span class="gps-sep">·</span>
@ -2047,11 +2051,13 @@
<hr> <hr>
<!-- Sign-out (only visible when authenticated) --> <!-- Logout (only visible when authenticated) — routes through the
server logout endpoint (/?logout=1) so the PWA's own PHP session
is destroyed, not just the SSO cookie. -->
<button type="button" id="menu-signout-btn" <button type="button" id="menu-signout-btn"
class="btn btn-outline-danger w-100 d-none" class="btn btn-outline-danger w-100 d-none"
style="font-weight:600;"> style="font-weight:600;">
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page <i class="bi bi-box-arrow-right me-2"></i>Logout
</button> </button>
<!-- Sign-in prompt (only visible when NOT authenticated) --> <!-- Sign-in prompt (only visible when NOT authenticated) -->
@ -2211,6 +2217,26 @@
</div> </div>
</div> </div>
</div> </div>
<!-- GPS Coordinate Format -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">GPS Coordinate Format</h6>
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
<option value="latlon">Lat / Lon</option>
<option value="utm">UTM</option>
<option value="both">Both</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map --> <!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4"> <div class="col-12 col-md-6 col-lg-4">
<div class="card"> <div class="card">

191
main.js
View File

@ -97,7 +97,7 @@ import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers
// GPS live-position + trail recording (reusable engine + LUPMIS wiring) // GPS live-position + trail recording (reusable engine + LUPMIS wiring)
import { geoTracker } from './src/geotracker-lupmis.js'; import { geoTracker } from './src/geotracker-lupmis.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js'; import { formatCoord, formatAccuracy, formatDistance, accuracyQuality, formatUTM } from './src/geotracker/geo-utils.js';
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx) // Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
import { createEmbedBridge } from './src/embed-bridge.js'; import { createEmbedBridge } from './src/embed-bridge.js';
@ -473,6 +473,14 @@ async function initApp() {
// Now dbReady should be resolved // Now dbReady should be resolved
console.log('[App] Database ready'); console.log('[App] Database ready');
// Guard against a stale district: if the authenticated user's district
// has changed since the last load (e.g. an admin reassigned them in the
// remote DB), every locally-cached, district-scoped layer is now wrong.
// Wipe them so the fresh-fetch path repopulates from the correct
// district instead of showing the previous one. Must run BEFORE any
// district-scoped load below.
await enforceDistrictConsistency();
// Show database status // Show database status
const status = await getDatabaseStatus(); const status = await getDatabaseStatus();
console.log('[App] Database status:', status); console.log('[App] Database status:', status);
@ -559,6 +567,9 @@ async function initApp() {
// 9. Measurement system toggle (metric / imperial) // 9. Measurement system toggle (metric / imperial)
initMeasurementSystem(); initMeasurementSystem();
// 9b. GPS coordinate format (lat/lon · UTM · both)
initCoordinateFormat();
// 10. Dark mode // 10. Dark mode
initDarkMode(); initDarkMode();
@ -1342,13 +1353,58 @@ function zonesToGeoJSON(zones) {
return { type: 'FeatureCollection', features }; return { type: 'FeatureCollection', features };
} }
/** localStorage key tracking the district the local caches were filled for. */
const LAST_DISTRICT_KEY = 'lupmis-last-district';
/** Cache key for the district boundary, scoped per district id. */
function districtBoundaryCacheKey(districtId) {
return districtId == null || String(districtId).length === 0
? 'district_boundary' // dev fallback (no session)
: `district_boundary_${districtId}`;
}
/**
* Detect a district change between page loads and wipe district-scoped local
* caches when it happens, so we never display a previous district's boundary,
* parcels, zones or UPN grid after an admin reassigns the user.
*
* The comparison is against the authenticated session district only in local
* dev (no session) there is no district to enforce, so this is a no-op.
*/
async function enforceDistrictConsistency() {
const session = getSession();
// Only meaningful for an authenticated user with a district.
const current = session?.district_id;
if (current == null || String(current).length === 0) return;
const currentStr = String(current);
let previous = null;
try { previous = localStorage.getItem(LAST_DISTRICT_KEY); } catch { /* ignore */ }
if (previous !== null && previous !== currentStr) {
console.warn(`[App] District changed ${previous}${currentStr}; clearing cached layers.`);
try {
await clearAllCachedLayers();
showWarning('Your district was updated — refreshing map data.');
} catch (err) {
console.error('[App] Failed to clear caches on district change:', err);
}
}
try { localStorage.setItem(LAST_DISTRICT_KEY, currentStr); } catch { /* ignore */ }
}
/** /**
* Load district boundary with local-first strategy: * Load district boundary with local-first strategy:
* 1. Always read from local SQLite cache (GeoJSON) first instant, works offline * 1. Always read from local SQLite cache (GeoJSON) first instant, works offline
* 2. If online, fetch from API, convert WKT GeoJSON, cache and display * 2. If online, fetch from API, convert WKT GeoJSON, cache and display
*
* The cache is keyed by district id, so a boundary cached for one district can
* never be served for another even if the stale-district guard is bypassed.
*/ */
async function loadDistrictBoundary() { async function loadDistrictBoundary() {
const CACHE_KEY = 'district_boundary'; const districtId = getSession()?.district_id ?? null;
const CACHE_KEY = districtBoundaryCacheKey(districtId);
const ADMIN_GROUP_ID = 1; // Administration layer group const ADMIN_GROUP_ID = 1; // Administration layer group
const boundaryStyle = { const boundaryStyle = {
strokeColor: '#e11d48', strokeColor: '#e11d48',
@ -1390,16 +1446,22 @@ async function loadDistrictBoundary() {
} }
try { try {
// Will we be fetching fresh data below? If so, the cached layer is shown
// for an instant paint but we DON'T zoom to it — the fresh fetch performs
// the single authoritative zoom, avoiding a visible "fly to old extent
// then re-fly" when the cached geometry differs.
const willFetchFresh = isOnline() && isServerReachable();
// Step 1: Load from local cache (already stored as GeoJSON) // Step 1: Load from local cache (already stored as GeoJSON)
const cached = await getRemoteData(CACHE_KEY); const cached = await getRemoteData(CACHE_KEY);
if (cached) { if (cached) {
console.log('[App] District boundary loaded from local cache'); console.log('[App] District boundary loaded from local cache');
const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup); const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup);
zoomToBoundary(layer); if (!willFetchFresh) zoomToBoundary(layer);
} }
// Step 2: If online and server reachable, fetch fresh data from the API // Step 2: If online and server reachable, fetch fresh data from the API
if (isOnline() && isServerReachable()) { if (willFetchFresh) {
console.log('[App] Fetching district boundary from API...'); console.log('[App] Fetching district boundary from API...');
const apiResponse = await getDistrictBoundary(); const apiResponse = await getDistrictBoundary();
@ -3115,6 +3177,48 @@ function initMessageLog() {
// events to the navbar readout and the map's render/control hooks. // events to the navbar readout and the map's render/control hooks.
// ============================================================================ // ============================================================================
// Last GPS fix rendered into the navbar read-out. Kept at module scope so the
// Coordinate-Format setting can re-render the current position immediately when
// the user switches format, without waiting for the next fix.
let _lastGpsFix = null;
/** Read the user's coordinate-format preference ('latlon' | 'utm' | 'both'). */
function getCoordFormat() {
const v = localStorage.getItem('coord-format');
return (v === 'utm' || v === 'both') ? v : 'latlon';
}
/**
* Paint the navbar GPS coordinate spans from `_lastGpsFix` according to the
* current coordinate-format preference. Safe to call any time (no-op when
* there is no fix yet, leaving the "GPS off" placeholder in place).
*/
function renderGpsCoords() {
const coordsEl = document.getElementById('gps-coords');
const utmEl = document.getElementById('gps-utm');
if (!coordsEl) return;
if (!_lastGpsFix) return;
const { lat, lon } = _lastGpsFix;
const latlon = `${formatCoord(lat)}, ${formatCoord(lon)}`;
const utm = formatUTM(lat, lon);
const fmt = getCoordFormat();
if (fmt === 'utm') {
coordsEl.textContent = utm;
if (utmEl) utmEl.classList.add('d-none');
} else if (fmt === 'both') {
coordsEl.textContent = latlon;
if (utmEl) { utmEl.textContent = utm; utmEl.classList.remove('d-none'); }
} else {
coordsEl.textContent = latlon;
if (utmEl) utmEl.classList.add('d-none');
}
// Keep the alternate format reachable on hover regardless of mode.
const readout = document.getElementById('gps-readout');
if (readout) readout.title = `Lat/Lon: ${latlon}\nUTM: ${utm}`;
}
function initGpsTracking() { function initGpsTracking() {
const readout = document.getElementById('gps-readout'); const readout = document.getElementById('gps-readout');
const coordsEl = document.getElementById('gps-coords'); const coordsEl = document.getElementById('gps-coords');
@ -3128,7 +3232,8 @@ function initGpsTracking() {
// Live navbar readout — fires for every fix (one-shot Locate or watch). // Live navbar readout — fires for every fix (one-shot Locate or watch).
geoTracker.on('position', (fix) => { geoTracker.on('position', (fix) => {
if (coordsEl) coordsEl.textContent = `${formatCoord(fix.lat)}, ${formatCoord(fix.lon)}`; _lastGpsFix = fix;
renderGpsCoords();
if (accEl) accEl.textContent = formatAccuracy(fix.accuracy); if (accEl) accEl.textContent = formatAccuracy(fix.accuracy);
if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`; if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`;
if (readout) { if (readout) {
@ -3323,6 +3428,25 @@ function initMeasurementSystem() {
}); });
} }
// ============================================================================
// GPS Coordinate Format (Lat/Lon · UTM · Both)
// ============================================================================
function initCoordinateFormat() {
const select = document.getElementById('coord-format-select');
if (!select) return;
// Restore saved preference (default: latlon)
select.value = getCoordFormat();
select.addEventListener('change', () => {
const fmt = (select.value === 'utm' || select.value === 'both') ? select.value : 'latlon';
localStorage.setItem('coord-format', fmt);
renderGpsCoords(); // repaint the current fix immediately
console.log('[Settings] GPS coordinate format:', fmt);
});
}
/** /**
* Default base map selector persisted in localStorage. * Default base map selector persisted in localStorage.
* Keys must match those handled by MapView.setBaseMap(). * Keys must match those handled by MapView.setBaseMap().
@ -3868,18 +3992,40 @@ function initAccountCard() {
// right-side menuOffcanvas. See initAccountCard above. // right-side menuOffcanvas. See initAccountCard above.
/** /**
* Sign-out flow: * Logout flow:
* 1. Confirm with the user. * 1. Confirm with the user.
* 2. Best-effort fire-and-forget call to the SSO logout endpoint so the * 2. Best-effort fire-and-forget call to the SSO logout endpoint so the
* server-side token is invalidated (no-cors mode tolerates CORS issues). * server-side token is invalidated (no-cors mode tolerates CORS issues).
* 3. Expire the local sso_auth_token cookie on the parent domain so the * 3. Wipe district-scoped local caches so the next (possibly different) user
* browser stops sending it. * on this device can't briefly see the previous user's cached map data.
* 4. Redirect to the SSO login page leaves the user on familiar ground * 4. Navigate to the PWA's OWN logout endpoint (/?logout=1). That endpoint
* (and on next visit, index.php sees no session and serves a fresh * runs session_destroy() clearing the PHPSESSID session that holds the
* page with no LUPMIS_SESSION). * user's district_id clears the sso_auth_token cookie server-side, and
* redirects to the central LUSPA portal. Clearing only the cookie here
* (as before) left the PHP session intact, which is what pinned users to
* a stale district across logout/login.
*/ */
async function handleSignOut(session) { async function handleSignOut(session) {
if (!confirm(`Return to Landing Page, ${session?.full_name || session?.username || 'user'}?`)) { // Guard: logging back in requires the SSO server (a session can only be
// created by validating the token online). If we let the user log out while
// offline, they would be stranded on this device — unable to sign back in
// until connectivity returns — and the server-side session_destroy() at
// /?logout=1 could not run anyway. So we refuse offline logout outright,
// which also protects against an accidental tap wiping the working session
// during fieldwork. The app keeps functioning offline on the cached session,
// so there is no need to log out until back online.
if (!isOnline()) {
alert(
'You appear to be offline.\n\n' +
'Logging out needs a connection to the sign-in server, and you would not ' +
'be able to sign back in until you are online again.\n\n' +
'Please try again once you have a connection. You can keep working — the ' +
'app stays signed in while offline.'
);
return;
}
if (!confirm(`Log out, ${session?.full_name || session?.username || 'user'}?`)) {
return; return;
} }
@ -3899,19 +4045,22 @@ async function handleSignOut(session) {
cache: 'no-store', cache: 'no-store',
}); });
} catch (err) { } catch (err) {
console.warn('[Signout] Best-effort SSO logout call failed:', err); console.warn('[Logout] Best-effort SSO logout call failed:', err);
} }
} }
// 2. Clear the cookie on the shared parent domain // 2. Drop locally-cached, district-scoped layers and the last-district
// Set with both leading-dot and no-dot variants; browsers vary on which sticks. // marker so a different next user starts clean.
const past = 'Thu, 01 Jan 1970 00:00:00 GMT'; try {
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=.lupmis4luspa.org`; await clearAllCachedLayers();
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=lupmis4luspa.org`; localStorage.removeItem(LAST_DISTRICT_KEY);
document.cookie = `sso_auth_token=; expires=${past}; path=/`; } catch (err) {
console.warn('[Logout] Cache clear failed (continuing):', err);
}
// 3. Redirect to the central LUSPA login // 3. Hand off to the server logout endpoint — it destroys the PHP session,
window.location.href = 'https://lupmis4luspa.org/'; // clears the SSO cookie, and 302-redirects to the LUSPA portal.
window.location.href = '/?logout=1';
} }
// ============================================================================ // ============================================================================

View File

@ -24,8 +24,42 @@
session_start(); session_start();
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session // Logout — end the PWA's OWN session
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
// the central SSO portal. Logging out of the SSO portal clears `sso_auth_token`
// but leaves this PHPSESSID session intact, so the previous user's fields
// (incl. district_id) persist. This endpoint is what actually destroys the PWA
// session. Triggered by `/?logout=1` from the in-app menu.
if (isset($_GET['logout'])) {
$_SESSION = [];
// Expire the session cookie itself.
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
// Clear the shared SSO cookie across all *.lupmis4luspa.org subdomains.
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
// Bounce to the central LUSPA portal to complete SSO logout.
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate once per session, at login
// ────────────────────────────────────────────────────────────────────────────
// A user's district changes only when they are transferred — a rare event — so
// the validated fields (district_id, region_id, name, …) are valid for the
// whole session. We therefore validate the SSO token only when there is no
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
//
// A transfer is picked up the next time the user logs out and back in: the
// Logout endpoint above destroys the PHP session, so the subsequent login has
// no session and re-validates, fetching the new district_id. This is correct
// precisely because logout now tears the session down (previously it only
// cleared the SSO cookie, which left the stale district pinned).
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) { if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token']; $plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken); $validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);

View File

@ -45,7 +45,15 @@
// tagging, geometry-edit + delete persistence to the staging tables, // tagging, geometry-edit + delete persistence to the staging tables,
// sample values + Unicode-bold field names in the mapping dropdown). // sample values + Unicode-bold field names in the mapping dropdown).
// New hashed bundle + updated embed.php shell. // New hashed bundle + updated embed.php shell.
const CACHE_VERSION = 'v11'; // v12: Session/district correctness — a real /?logout=1 endpoint that destroys
// the PWA's own PHP session (logout previously only cleared the SSO
// cookie, leaving the district_id pinned). SSO is validated once per
// session at login; a transfer is picked up on the next logout→login.
// Client-side stale-district guard (wipes district-scoped caches when the
// session district changes) + district-keyed boundary cache; GPS read-out
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
// devices. New hashed bundle + updated index.php shell.
const CACHE_VERSION = 'v12';
const SHELL_CACHE = `shell-${CACHE_VERSION}`; const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -669,19 +669,44 @@ export class MapView {
}); });
// 8. Touch-device detection & TouchCursor setup // 8. Touch-device detection & TouchCursor setup
const isTouchDevice = ('ontouchstart' in window) || //
(navigator.maxTouchPoints > 0) || // The TouchCursor is only practical on *touch-only* devices (phones,
(navigator.msMaxTouchPoints > 0); // tablets) where the finger is the sole pointing device. On hybrid
// laptops — a touchscreen plus a touchpad/mouse — the changed cursor
if (isTouchDevice) { // gets in the way, because the user is most likely driving the map
// with the precise pointer, not the screen.
//
// The classic `'ontouchstart' in window` / `maxTouchPoints` test can't
// tell the two apart: it is true for both a tablet and a touchscreen
// laptop. We additionally consult CSS Media Queries Level 4 pointer
// features, which DO distinguish them:
//
// any-pointer: fine → at least one fine pointer exists
// (mouse / touchpad / stylus). True on a
// touch laptop, false on a phone/tablet.
// any-hover: hover → at least one device can hover. Same split.
//
// So we enable the TouchCursor only when the device is touch-capable
// AND exposes no fine/hovering pointer — i.e. a genuine touch-only
// device. `_isTouchOnlyDevice()` is also reactive: if the user later
// plugs in a mouse, `_refreshTouchCursor()` tears the cursor down.
if (this._isTouchOnlyDevice()) {
this.touchCursor = new TouchCursor({ this.touchCursor = new TouchCursor({
className: 'ol-editbar-cursor', className: 'ol-editbar-cursor',
}); });
this.map.addInteraction(this.touchCursor); this.map.addInteraction(this.touchCursor);
this.touchCursor.setActive(false); this.touchCursor.setActive(false);
console.log('[MapView] Touch device detected — TouchCursor added'); console.log('[MapView] Touch-only device detected — TouchCursor added');
} else {
console.log('[MapView] Fine pointer available — TouchCursor skipped');
} }
// React to pointer-capability changes (e.g. a Bluetooth mouse paired
// with a tablet, or a tablet docked to a trackpad keyboard). When a
// fine pointer appears we drop the TouchCursor; when the last one is
// removed on a touch device we add it back.
this._installPointerCapabilityWatcher();
// 9. Listen for polygon features drawn via EditBar's DrawPolygon tool. // 9. Listen for polygon features drawn via EditBar's DrawPolygon tool.
// When a Polygon is added to the drawings source, show the attribute popup. // When a Polygon is added to the drawings source, show the attribute popup.
this.drawingsSource.on('addfeature', (evt) => { this.drawingsSource.on('addfeature', (evt) => {
@ -781,6 +806,85 @@ export class MapView {
return this._editBarActive; return this._editBarActive;
} }
// ============================================================================
// Pointer-capability detection (touch-only vs. hybrid laptop)
// ============================================================================
/**
* Decide whether the ol-ext TouchCursor should be used.
*
* Returns true ONLY for genuine touch-only devices (phones, tablets)
* i.e. devices that are touch-capable but expose no fine pointer and no
* hover capability. Hybrid laptops (touchscreen + touchpad/mouse) return
* false, so they keep the normal cursor.
*
* @returns {boolean}
*/
_isTouchOnlyDevice() {
const hasTouch = ('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0);
if (!hasTouch) return false;
// Without matchMedia we can't refine the signal — fall back to the
// historical behaviour (treat any touch device as touch).
if (typeof window.matchMedia !== 'function') return true;
// `any-pointer: fine` is true when ANY attached pointing device is
// fine (mouse / touchpad / stylus); `any-hover: hover` when ANY device
// can hover. A phone/tablet satisfies neither; a touch laptop both.
const hasFinePointer = window.matchMedia('(any-pointer: fine)').matches;
const canHover = window.matchMedia('(any-hover: hover)').matches;
return !hasFinePointer && !canHover;
}
/**
* Add or remove the TouchCursor to match the current pointer capability,
* preserving its active state relative to edit mode. Called both at init
* and whenever the pointer-capability media queries change.
*/
_refreshTouchCursor() {
const wantCursor = this._isTouchOnlyDevice();
if (wantCursor && !this.touchCursor) {
this.touchCursor = new TouchCursor({ className: 'ol-editbar-cursor' });
this.map.addInteraction(this.touchCursor);
this.touchCursor.setActive(this._editBarActive);
console.log('[MapView] Pointer change → TouchCursor added');
} else if (!wantCursor && this.touchCursor) {
this.map.removeInteraction(this.touchCursor);
this.touchCursor = null;
console.log('[MapView] Pointer change → TouchCursor removed (fine pointer present)');
}
}
/**
* Watch the pointer-capability media queries and re-evaluate the
* TouchCursor when they change e.g. a Bluetooth mouse paired with a
* tablet, or a 2-in-1 docked to / undocked from a keyboard-trackpad.
*/
_installPointerCapabilityWatcher() {
if (typeof window.matchMedia !== 'function') return;
if (this._pointerMediaQueries) return; // already installed
const queries = ['(any-pointer: fine)', '(any-hover: hover)']
.map((q) => window.matchMedia(q));
const onChange = () => this._refreshTouchCursor();
for (const mq of queries) {
// addEventListener is the modern API; addListener is the legacy
// fallback for older Safari.
if (typeof mq.addEventListener === 'function') {
mq.addEventListener('change', onChange);
} else if (typeof mq.addListener === 'function') {
mq.addListener(onChange);
}
}
this._pointerMediaQueries = queries;
this._pointerMediaListener = onChange;
}
// ============================================================================ // ============================================================================
// Persistent Vertex Highlight Overlay // Persistent Vertex Highlight Overlay
// ============================================================================ // ============================================================================

View File

@ -52,6 +52,101 @@ export function formatCoord(value, decimals = 5) {
return value.toFixed(decimals); return value.toFixed(decimals);
} }
// ---------------------------------------------------------------------------
// UTM (Universal Transverse Mercator) — WGS84 forward projection
//
// Self-contained implementation of the standard USGS series-expansion forward
// formulas (Snyder, "Map Projections — A Working Manual"). Accurate to a few
// millimetres within a zone, which is far beyond what a consumer GPS fix
// needs. Ghana spans UTM zones 30N and 31N, but this handles the whole globe.
// No proj4 / OpenLayers dependency so it stays usable from the pure GeoTracker
// layer and from plain display code.
// ---------------------------------------------------------------------------
const UTM_A = 6378137.0; // WGS84 semi-major axis (metres)
const UTM_F = 1 / 298.257223563; // WGS84 flattening
const UTM_K0 = 0.9996; // UTM scale factor on the central meridian
const UTM_E2 = UTM_F * (2 - UTM_F); // first eccentricity squared
const UTM_EP2 = UTM_E2 / (1 - UTM_E2); // second eccentricity squared (e'^2)
/**
* Convert WGS84 latitude/longitude to UTM.
*
* @param {number} lat latitude in decimal degrees (80 84 is the UTM band)
* @param {number} lon longitude in decimal degrees (180 180)
* @returns {{zone:number, hemisphere:'N'|'S', easting:number, northing:number}|null}
* null when the input is missing / not finite.
*/
export function latLonToUTM(lat, lon) {
if (lat == null || lon == null || Number.isNaN(lat) || Number.isNaN(lon)) return null;
// Normalise longitude to [180, 180) so the zone maths is well-defined.
let lonNorm = ((lon + 180) % 360 + 360) % 360 - 180;
const zone = Math.floor((lonNorm + 180) / 6) + 1;
const lon0 = (zone - 1) * 6 - 180 + 3; // central meridian of the zone (deg)
const phi = lat * DEG2RAD;
const lambda = lonNorm * DEG2RAD;
const lambda0 = lon0 * DEG2RAD;
const sinPhi = Math.sin(phi);
const cosPhi = Math.cos(phi);
const tanPhi = Math.tan(phi);
const N = UTM_A / Math.sqrt(1 - UTM_E2 * sinPhi * sinPhi);
const T = tanPhi * tanPhi;
const C = UTM_EP2 * cosPhi * cosPhi;
const A = cosPhi * (lambda - lambda0);
// Meridional arc length from the equator to the given latitude.
const M = UTM_A * (
(1 - UTM_E2 / 4 - 3 * UTM_E2 ** 2 / 64 - 5 * UTM_E2 ** 3 / 256) * phi
- (3 * UTM_E2 / 8 + 3 * UTM_E2 ** 2 / 32 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(2 * phi)
+ (15 * UTM_E2 ** 2 / 256 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(4 * phi)
- (35 * UTM_E2 ** 3 / 3072) * Math.sin(6 * phi)
);
let easting = UTM_K0 * N * (
A
+ (1 - T + C) * A ** 3 / 6
+ (5 - 18 * T + T * T + 72 * C - 58 * UTM_EP2) * A ** 5 / 120
) + 500000; // false easting
let northing = UTM_K0 * (
M + N * tanPhi * (
A * A / 2
+ (5 - T + 9 * C + 4 * C * C) * A ** 4 / 24
+ (61 - 58 * T + T * T + 600 * C - 330 * UTM_EP2) * A ** 6 / 720
)
);
const hemisphere = lat >= 0 ? 'N' : 'S';
if (hemisphere === 'S') northing += 10000000; // false northing (southern hemi)
return {
zone,
hemisphere,
easting: Math.round(easting),
northing: Math.round(northing),
};
}
/**
* Format a latitude/longitude as a compact UTM string for display, e.g.
* `30N 706123E 619876N`. Returns the em-dash placeholder when the position
* is unavailable.
*
* @param {number} lat
* @param {number} lon
* @returns {string}
*/
export function formatUTM(lat, lon) {
const u = latLonToUTM(lat, lon);
if (!u) return '—';
return `${u.zone}${u.hemisphere} ${u.easting}E ${u.northing}N`;
}
/** /**
* Format a distance in metres into a friendly string (m below 1 km, km above). * Format a distance in metres into a friendly string (m below 1 km, km above).
* @param {number} meters * @param {number} meters