Legg til om-side, personvernerklæring og sikkerheitsforbetring

Om-side (Android + PWA):
- Ny AboutDialog med personvernerklæring, datakjelder og opphavsrett
- Opphavsrett flytta frå sivilforsvardialogen til om-sida
- Tilgjengeleg via «Om denne appen»-lenke i sivilforsvarsdialogen (Android)
  og ny om-knapp i statuslinja (PWA)
- Lokalisert til en/nb/nn

Personvern og sikkerheit:
- Lagra GPS-posisjon utløper etter 24 timar (widget_prefs)
- Widget viser «Trykk for å oppdatere» når posisjon manglar eller er utløpt
- Eigendefinert User-Agent (Tilfluktsrom/1.6.1) i OkHttp
- Content Security Policy (CSP) meta-tag i PWA
- Tenararbeidar bufrar berre HTTP 200-svar (ikkje opake)
- Kartbuffer-metadata runda til ~11km presisjon i localStorage
- crossorigin="anonymous" på Leaflet CSS

i18n-opprydding:
- Unicode-escapes erstatta med UTF-8-teikn i nb.ts og nn.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 14:27:45 +01:00
commit c1ac68e746
21 changed files with 469 additions and 34 deletions

View file

@ -5,19 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1A1A2E" />
<meta name="description" content="Find the nearest public shelter in Norway" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com; connect-src 'self' https://*.tile.openstreetmap.org; font-src 'self'; worker-src 'self'" />
<title>Tilfluktsrom</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
crossorigin="anonymous" />
</head>
<body>
<div id="app">
<!-- Status bar -->
<header id="status-bar" role="banner">
<span id="status-text" aria-live="polite"></span>
<button id="about-btn" aria-label="About">&#x2139;</button>
<button id="refresh-btn" aria-label="Refresh data">&#x21bb;</button>
</header>

View file

@ -20,6 +20,7 @@ import * as shelterList from './ui/shelter-list';
import * as statusBar from './ui/status-bar';
import * as loading from './ui/loading-overlay';
import * as mapCache from './cache/map-cache-manager';
import * as aboutDialog from './ui/about-dialog';
const NEAREST_COUNT = 3;
@ -44,8 +45,13 @@ export async function init(): Promise<void> {
await loadData();
}
/** Set localized aria-labels on landmark elements. */
/** Set localized aria-labels and wire the about button. */
function applyA11yLabels(): void {
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_about'));
document.getElementById('about-btn')?.addEventListener('click', () => {
navigator.vibrate?.(10);
aboutDialog.showAbout();
});
document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map'));
document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass'));
document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));

View file

@ -94,9 +94,10 @@ export async function cacheMapArea(
// Restore original view
map.setView(originalCenter, originalZoom, { animate: false });
// Round coordinates to 1 decimal (~11km) to limit location precision in storage
saveCacheMeta({
lat,
lon,
lat: Math.round(lat * 10) / 10,
lon: Math.round(lon * 10) / 10,
radius: CACHE_RADIUS_DEGREES,
complete: true,
});

View file

@ -53,4 +53,22 @@ export const en: Record<string, string> = {
a11y_compass: 'Compass',
a11y_shelter_info: 'Shelter info',
a11y_nearest_shelters: 'Nearest shelters',
// About
about_title: 'About Tilfluktsrom',
about_description:
'Tilfluktsrom helps you find the nearest public shelter in Norway. The app works offline after initial setup.',
about_privacy_title: 'Privacy',
about_privacy_body:
'This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services. Your GPS location is used only on your device to find nearby shelters and is never sent to any server.',
about_data_title: 'Data sources',
about_data_body:
'Shelter data: Geonorge (Norwegian Mapping Authority). Map tiles: OpenStreetMap. Both are cached locally for offline use.',
about_stored_title: 'Stored on your device',
about_stored_body:
'Shelter database (public data), map tiles for offline use, and map cache metadata. No data leaves your device except requests to download shelter data and map tiles.',
about_copyright: 'Copyright © Ole-Morten Duesund',
about_open_source: 'Open source — kode.naiv.no/olemd/tilfluktsrom',
action_about: 'About',
action_close: 'Close',
};

View file

@ -1,4 +1,4 @@
/** Norwegian Bokm\u00e5l strings. Ported from res/values-nb/strings.xml. */
/** Norwegian Bokmål strings. Ported from res/values-nb/strings.xml. */
export const nb: Record<string, string> = {
app_name: 'Tilfluktsrom',
@ -7,40 +7,40 @@ export const nb: Record<string, string> = {
status_updating: 'Oppdaterer\u2026',
status_offline: 'Frakoblet modus',
status_shelters_loaded: '%d tilfluktsrom lastet',
status_no_location: 'Venter p\u00e5 GPS\u2026',
status_no_location: 'Venter på GPS\u2026',
status_caching_map: 'Lagrer kart for frakoblet bruk\u2026',
loading_shelters: 'Laster ned tilfluktsromdata\u2026',
loading_map: 'Lagrer kartfliser\u2026',
loading_map_explanation:
'Forbereder frakoblet kart.\nKartet vil rulle kort for \u00e5 lagre omgivelsene dine.',
loading_first_time: 'Gj\u00f8r klar for f\u00f8rste gangs bruk\u2026',
'Forbereder frakoblet kart.\nKartet vil rulle kort for å lagre omgivelsene dine.',
loading_first_time: 'Gjør klar for første gangs bruk\u2026',
shelter_capacity: '%d plasser',
shelter_room_nr: 'Rom %d',
nearest_shelter: 'N\u00e6rmeste tilfluktsrom',
nearest_shelter: 'Nærmeste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengelig',
action_refresh: 'Oppdater data',
action_toggle_view: 'Bytt mellom kart og kompassvisning',
action_skip: 'Hopp over',
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre n\u00e5',
action_cache_now: 'Lagre nå',
warning_no_map_cache:
'Ingen frakoblet kart lagret. Kartet krever internett.',
permission_location_title: 'Posisjonstillatelse kreves',
permission_location_message:
'Denne appen trenger din posisjon for \u00e5 finne n\u00e6rmeste tilfluktsrom. Vennligst gi tilgang til posisjon.',
'Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon.',
permission_denied:
'Posisjonstillatelse avsl\u00e5tt. Appen kan ikke finne tilfluktsrom i n\u00e6rheten uten den.',
'Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den.',
error_download_failed:
'Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.',
error_no_data_offline:
'Ingen lagrede data tilgjengelig. Koble til internett for \u00e5 laste ned tilfluktsromdata.',
'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislyktes \u2014 bruker lagrede data',
update_failed: 'Oppdatering mislyktes bruker lagrede data',
// Tilgjengelighet
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
@ -48,4 +48,22 @@ export const nb: Record<string, string> = {
a11y_compass: 'Kompass',
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmeste tilfluktsrom',
// Om
about_title: 'Om Tilfluktsrom',
about_description:
'Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen fungerer uten internett etter første oppsett.',
about_privacy_title: 'Personvern',
about_privacy_body:
'Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester. GPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.',
about_data_title: 'Datakilder',
about_data_body:
'Tilfluktsromdata: Geonorge (Kartverket). Kartfliser: OpenStreetMap. Begge lagres lokalt for frakoblet bruk.',
about_stored_title: 'Lagret på enheten din',
about_stored_body:
'Tilfluktsromdatabase (offentlige data), kartfliser for frakoblet bruk og kartbuffer-metadata. Ingen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.',
about_copyright: 'Opphavsrett © Ole-Morten Duesund',
about_open_source: 'Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom',
action_about: 'Om',
action_close: 'Lukk',
};

View file

@ -5,20 +5,20 @@ export const nn: Record<string, string> = {
status_ready: 'Klar',
status_loading: 'Lastar tilfluktsromdata\u2026',
status_updating: 'Oppdaterer\u2026',
status_offline: 'Fr\u00e5kopla modus',
status_offline: 'Fråkopla modus',
status_shelters_loaded: '%d tilfluktsrom lasta',
status_no_location: 'Ventar p\u00e5 GPS\u2026',
status_caching_map: 'Lagrar kart for fr\u00e5kopla bruk\u2026',
status_no_location: 'Ventar på GPS\u2026',
status_caching_map: 'Lagrar kart for fråkopla bruk\u2026',
loading_shelters: 'Lastar ned tilfluktsromdata\u2026',
loading_map: 'Lagrar kartfliser\u2026',
loading_map_explanation:
'F\u00f8rebur fr\u00e5kopla kart.\nKartet vil rulle kort for \u00e5 lagre omgjevnadene dine.',
'Førebur fråkopla kart.\nKartet vil rulle kort for å lagre omgjevnadene dine.',
loading_first_time: 'Gjer klar for fyrste gongs bruk\u2026',
shelter_capacity: '%d plassar',
shelter_room_nr: 'Rom %d',
nearest_shelter: 'N\u00e6raste tilfluktsrom',
nearest_shelter: 'Næraste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengeleg',
action_refresh: 'Oppdater data',
@ -27,20 +27,20 @@ export const nn: Record<string, string> = {
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre no',
warning_no_map_cache:
'Ingen fr\u00e5kopla kart lagra. Kartet krev internett.',
'Ingen fråkopla kart lagra. Kartet krev internett.',
permission_location_title: 'Posisjonsløyve krevst',
permission_location_message:
'Denne appen treng posisjonen din for \u00e5 finne n\u00e6raste tilfluktsrom. Ver venleg og gje tilgang til posisjon.',
'Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon.',
permission_denied:
'Posisjonsløyve avsl\u00e5tt. Appen kan ikkje finne tilfluktsrom i n\u00e6rleiken utan det.',
'Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det.',
error_download_failed:
'Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.',
error_no_data_offline:
'Ingen lagra data tilgjengeleg. Kopla til internett for \u00e5 laste ned tilfluktsromdata.',
'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislukkast \u2014 brukar lagra data',
update_failed: 'Oppdatering mislukkast brukar lagra data',
// Tilgjenge
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
@ -48,4 +48,22 @@ export const nn: Record<string, string> = {
a11y_compass: 'Kompass',
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmaste tilfluktsrom',
// Om
about_title: 'Om Tilfluktsrom',
about_description:
'Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen fungerer utan internett etter fyrste oppsett.',
about_privacy_title: 'Personvern',
about_privacy_body:
'Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester. GPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.',
about_data_title: 'Datakjelder',
about_data_body:
'Tilfluktsromdata: Geonorge (Kartverket). Kartfliser: OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.',
about_stored_title: 'Lagra på eininga di',
about_stored_body:
'Tilfluktsromdatabase (offentlege data), kartfliser for fråkopla bruk og kartbuffer-metadata. Ingen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.',
about_copyright: 'Opphavsrett © Ole-Morten Duesund',
about_open_source: 'Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom',
action_about: 'Om',
action_close: 'Lukk',
};

View file

@ -62,6 +62,7 @@ html, body {
text-overflow: ellipsis;
}
#about-btn,
#refresh-btn {
background: none;
border: none;
@ -73,6 +74,7 @@ html, body {
flex-shrink: 0;
}
#about-btn:hover,
#refresh-btn:hover {
color: #ECEFF1;
}
@ -399,3 +401,71 @@ html, body {
.leaflet-popup-close-button {
color: #CFD8DC !important;
}
/* --- About dialog --- */
#about-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.about-content {
background: #16213E;
border-radius: 12px;
padding: 24px;
max-width: 480px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.about-heading {
color: #FF6B35;
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
}
.about-subheading {
color: #ECEFF1;
font-size: 16px;
font-weight: bold;
margin-top: 16px;
margin-bottom: 4px;
}
.about-para {
color: #90A4AE;
font-size: 14px;
line-height: 1.5;
margin-bottom: 8px;
}
.about-footer {
margin-top: 20px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.about-small {
color: #90A4AE;
font-size: 11px;
margin-bottom: 2px;
}
.about-close-btn {
display: block;
margin: 16px auto 0;
padding: 10px 32px;
background: #FF6B35;
border: none;
border-radius: 6px;
color: #FFFFFF;
font-size: 14px;
cursor: pointer;
}

View file

@ -0,0 +1,92 @@
/**
* About dialog: app info, privacy statement, data sources, copyright.
* Opens as a modal overlay, same pattern as loading-overlay.
*/
import { t } from '../i18n/i18n';
let overlay: HTMLDivElement | null = null;
let previousFocus: HTMLElement | null = null;
/** Show the about dialog. */
export function showAbout(): void {
if (overlay) return;
previousFocus = document.activeElement as HTMLElement | null;
overlay = document.createElement('div');
overlay.id = 'about-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', t('about_title'));
const content = document.createElement('div');
content.className = 'about-content';
content.appendChild(heading(t('about_title')));
content.appendChild(para(t('about_description')));
content.appendChild(subheading(t('about_privacy_title')));
content.appendChild(para(t('about_privacy_body')));
content.appendChild(subheading(t('about_data_title')));
content.appendChild(para(t('about_data_body')));
content.appendChild(subheading(t('about_stored_title')));
content.appendChild(para(t('about_stored_body')));
const footer = document.createElement('div');
footer.className = 'about-footer';
footer.appendChild(small(t('about_copyright')));
footer.appendChild(small(t('about_open_source')));
content.appendChild(footer);
const closeBtn = document.createElement('button');
closeBtn.className = 'about-close-btn';
closeBtn.textContent = t('action_close');
closeBtn.addEventListener('click', hideAbout);
content.appendChild(closeBtn);
overlay.appendChild(content);
document.body.appendChild(overlay);
closeBtn.focus();
}
/** Hide the about dialog and restore focus. */
export function hideAbout(): void {
if (overlay) {
overlay.remove();
overlay = null;
}
previousFocus?.focus();
previousFocus = null;
}
function heading(text: string): HTMLElement {
const el = document.createElement('h2');
el.textContent = text;
el.className = 'about-heading';
return el;
}
function subheading(text: string): HTMLElement {
const el = document.createElement('h3');
el.textContent = text;
el.className = 'about-subheading';
return el;
}
function para(text: string): HTMLElement {
const el = document.createElement('p');
el.textContent = text;
el.className = 'about-para';
return el;
}
function small(text: string): HTMLElement {
const el = document.createElement('p');
el.textContent = text;
el.className = 'about-small';
return el;
}

View file

@ -39,7 +39,7 @@ export default defineConfig({
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
cacheableResponse: {
statuses: [0, 200],
statuses: [200],
},
},
},