From f0b4d735b50d454e8b40f55787a1c94839e7e685 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 12:57:59 +0200 Subject: [PATCH] Public landing, owner-list links, owner-conditional semi, PWA + mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four related UX/privacy/install changes. 1. **Logged-out lands on the public list.** The root route now shows the same Home view as a logged-in user, minus their own private rows and the "Ny aktivitet" button. The nav exposes a "Logg inn" button when no session is present. Login becomes one click away, not the forced landing — anyone can browse the public + semi list anonymously. 2. **Public activities link to //list.** When a public activity's owner has opted into a public list, the "Lagt til av X" line renders X as a link to //list. Server populates `owner_username` on every public-row serialisation (null when the owner hasn't opted in, so the client just renders plain text). 3. **Conditional owner_id on semi rows.** The server now serialises `owner_id` on a semi row ONLY when the viewer IS the owner. The wire type's `ActivitySemi.owner_id` is therefore optional. This solves the semi-delete UX without leaking attribution: owners see Edit/Delete buttons on their own semi rows; non-owners get the same bare row they got before. The privacy property is enforced at the API boundary, not in client-side render logic. 4. **Mobile-friendly + installable PWA.** - `manifest.webmanifest` with name, theme color, standalone display, and a maskable SVG icon (icon.svg). - Service worker (sw.js): cache-first for the bundled shell; network-only for /api/* (we never cache session-dependent or ciphertext data — see the comment in sw.js for the rationale). Falls back to the SPA shell for navigation requests when offline. - SW registered in main.ts only in production builds (import.meta.env.PROD). - viewport-fit=cover + env(safe-area-inset-*) padding so content doesn't slip under iOS notches when installed. - WCAG 2.5.5 touch-target sizing: min-height: 44px on buttons, with an explicit opt-out for tag-close buttons (24×24 still meets the 2.5.8 minimum). - 16px font on form inputs below 480px so iOS doesn't auto-zoom. Server-side: server/index.ts now serves manifest, icon, and sw.js from frontend/dist alongside /assets/*. The catch-all still serves index.html so the SPA's //list path routing keeps working. Smoke-tested with a production-mode server: manifest returns the correct application/manifest+json MIME, SVG renders, sw.js is loadable, and unknown paths fall through to index.html as expected. 26 tests still pass; both tsconfigs typecheck (frontend now pulls vite/client types for import.meta.env.PROD); Vite build succeeds. --- README.md | 17 +++++ frontend/index.html | 9 ++- frontend/public/icon.svg | 54 ++++++++++++++++ frontend/public/manifest.webmanifest | 20 ++++++ frontend/public/sw.js | 73 ++++++++++++++++++++++ frontend/src/App.svelte | 40 +++++++----- frontend/src/components/ActivityRow.svelte | 32 +++++----- frontend/src/components/Home.svelte | 19 ++++-- frontend/src/main.ts | 12 ++++ frontend/src/styles.css | 36 ++++++++++- frontend/tsconfig.json | 2 +- server/activities.ts | 32 +++++++--- server/index.ts | 10 ++- server/users.ts | 3 + shared/types.ts | 12 +++- 15 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 frontend/public/icon.svg create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/sw.js diff --git a/README.md b/README.md index a5554ce..2ec0167 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ podman run --replace --name vinterliste \ The container exposes `/api/health` for healthchecks and bakes the build date / git revision into both OCI labels and `/etc/build-info`. +## Installable (PWA) + mobile + +The SPA ships with a web app manifest (`/manifest.webmanifest`), an SVG icon +(`/icon.svg`), and a small service worker (`/sw.js`) that caches the bundled +shell for offline reads. The API itself is **never** cached — sessions and +ciphertexts must come fresh from the server. On supported browsers +(Chrome/Edge on Android and desktop, Firefox with the flag) you'll see an +"Install" prompt; on iOS you can Add to Home Screen but iOS doesn't render +SVG icons, so the home-screen icon will fall back to the page screenshot. + +Layout adapts to small screens via: + +- `viewport` set to `width=device-width, initial-scale=1, viewport-fit=cover` +- safe-area insets in `padding` so content doesn't slip under iOS notches +- `min-height: 44px` on buttons (WCAG 2.5.5 enhanced touch target) +- `font-size: 16px` on inputs below 480px so iOS doesn't auto-zoom + ## Promoting a moderator Moderators can delete any `semi` or `public` activity (not `private` — those diff --git a/frontend/index.html b/frontend/index.html index 5abf4f8..cc7016f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,15 @@ - + + + + + + + Vinterliste diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..bf53096 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..85ff86d --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Vinterliste", + "short_name": "Vinterliste", + "description": "Ende-til-ende-kryptert liste over vinteraktiviteter", + "lang": "nb-NO", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#161616", + "theme_color": "#1f6feb", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..2fc9c0f --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,73 @@ +// Vinterliste service worker. +// +// Two strategies: +// - Static assets (the bundled JS/CSS under /assets/, plus the SPA shell): +// cache-first. The bundle filenames are hashed by Vite, so a new deploy +// ships under a new URL and gets fetched fresh; old entries are pruned +// when the SW version (CACHE_NAME) bumps. +// - API calls (/api/*): network-only. We never cache responses that depend +// on the caller's session — that'd be a privacy disaster for an E2E app. +// Letting them fail with a network error when offline is the right shape: +// the UI shows the existing "Kunne ikke laste …" path. +// +// IMPORTANT: never cache responses that contain ciphertext or any decrypted +// payload. Private activity ciphertexts are technically safe to cache (they +// require the in-memory DEK to read), but the simpler and safer rule is "no +// API caching at all" so we don't accidentally land in trouble later. + +const CACHE_NAME = 'vinterliste-v1'; +const SHELL_PATHS = ['/', '/index.html', '/icon.svg', '/favicon.svg', '/manifest.webmanifest']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_PATHS)).catch(() => null), + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))), + ), + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + + const url = new URL(req.url); + + // Same-origin only — never touch third-party requests. + if (url.origin !== self.location.origin) return; + + // API: network-only. Don't cache session-dependent data. + if (url.pathname.startsWith('/api/')) return; + + // Everything else: cache-first with network fallback that backfills the cache. + event.respondWith( + caches.match(req).then((cached) => { + if (cached) return cached; + return fetch(req) + .then((res) => { + // Only cache successful, basic (same-origin) responses. + if (res && res.status === 200 && res.type === 'basic') { + const copy = res.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => null); + } + return res; + }) + .catch(() => { + // Offline + nothing cached. For navigation requests, fall back to the + // SPA shell so the user at least gets a "you're offline" frame the + // app can render. For other requests, let the failure surface. + if (req.mode === 'navigate') { + return caches.match('/index.html'); + } + throw new Error('offline'); + }); + }), + ); +}); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 16f19d2..9cc68cb 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -45,13 +45,15 @@ try { const me = await api.me(); // We have an active server session but no DEK — the user reloaded the - // page. Force them through the login screen so we can re-unlock. + // page. Drop the stale server session and pre-fill their email on the + // login form, but otherwise show the same public landing as any other + // logged-out visitor. They can hit "Logg inn" when they want to unlock. defaultEmail = me.email; - await api.logout(); // drop the stale server session - view = 'login'; - } catch (err) { - if (err instanceof ApiError && err.status === 401) view = 'login'; - else view = 'login'; + await api.logout(); + view = 'home'; + } catch { + // No session (or expired). Show the public landing. + view = 'home'; } }); @@ -77,17 +79,23 @@
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 3a9064d..161a834 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -68,20 +68,17 @@ // Authz mirrors server/activities.ts: // - private: owner-only (server only returns yours anyway) - // - public: owner OR moderator - // - semi: owner OR moderator (but UI can't tell ownership from the - // row because owner_id isn't serialised; we expose Delete only - // to moderators; owners must use server-side ownership check - // when they hit it — but in this UI we can't show a per-row - // "you own this" hint without leaking. Acceptable tradeoff - // for now; semi-owner-delete still works via Edit path that - // the server authorises). + // - public: owner if session.user.id === owner_id + // - semi: owner if the server included owner_id in this row — the + // server only sends it to the owner, so its presence is the + // ownership signal. Anyone else sees no owner_id and no buttons. + // - moderators can delete (but not edit) any non-private row. const isOwner = $derived( - activity.visibility === 'public' - ? session.user?.id === activity.owner_id - : activity.visibility === 'private' - ? true - : false, + activity.visibility === 'private' + ? true + : activity.visibility === 'public' + ? session.user?.id === activity.owner_id + : 'owner_id' in activity && activity.owner_id === session.user?.id, ); const isModerator = $derived(session.user?.is_moderator === true); const canEdit = $derived(isOwner); // editing always requires ownership @@ -156,7 +153,14 @@ {/if} {#if activity.scheduled_at}

🕒 {formatDate(activity.scheduled_at)}

{/if} {#if activity.visibility === 'public'} -

Lagt til av {activity.owner_display}

+

+ Lagt til av + {#if activity.owner_username} + {activity.owner_display} + {:else} + {activity.owner_display} + {/if} +

{/if} {/if} diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index 71fc54d..e65137f 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -93,12 +93,19 @@
-

- Velkommen, {session.user?.display_name?.trim() || session.user?.email}. - Her er aktivitetene dine for vinteren. -

- {#if !showForm && !editing} - + {#if session.user} +

+ Velkommen, {session.user.display_name?.trim() || session.user.email}. + Her er aktivitetene dine for vinteren. +

+ {#if !showForm && !editing} + + {/if} + {:else} +

+ Offentlige og halv-offentlige aktiviteter. Logg inn for å legge til + dine egne — privat eller offentlig. +

{/if}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b4a8bac..e515dff 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,3 +6,15 @@ const target = document.getElementById('app'); if (!target) throw new Error('No #app element in DOM'); mount(App, { target }); + +// Register the service worker so the SPA is installable + cached for offline +// reads of the shell. Production only — Vite's dev server already does its +// own SW dance and registering ours would step on hot module reload. Errors +// are swallowed: the SW is an enhancement, not a requirement for the app. +if ('serviceWorker' in navigator && import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => { + // SW unavailable (file://, private mode in some browsers, etc.). Ignore. + }); + }); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 66c2410..7952769 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -32,12 +32,17 @@ html, body { background: var(--bg); color: var(--fg); min-height: 100vh; + /* iOS double-tap zoom is annoying for an app like this; the meta viewport + handles the rest. */ + -webkit-text-size-adjust: 100%; } main { max-width: 720px; margin: 0 auto; - padding: 1.5rem 1rem 4rem; + /* Use env() for safe-area insets so content doesn't slip under iOS notches + or under Android gesture areas when installed as a PWA. */ + padding: 1.5rem max(1rem, env(safe-area-inset-right)) calc(4rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); } h1, h2, h3 { line-height: 1.2; margin-top: 0; } @@ -68,6 +73,9 @@ input:focus-visible, button:focus-visible, select:focus-visible, textarea:focus- button { cursor: pointer; + /* WCAG 2.5.5 enhanced target size is 44×44 CSS px; we hit that with the + vertical padding + line-height. Don't shrink below this on small viewports. */ + min-height: 44px; padding: 0.5rem 0.9rem; border-radius: 8px; border: 1px solid var(--border); @@ -117,6 +125,14 @@ label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; } margin: 0.15rem 0.2rem 0.15rem 0; } .tag.private { background: rgba(31,111,235,0.15); } +/* Tag-close buttons opt out of the 44px touch target — they're 24×24 (WCAG + 2.5.8 minimum) which is still large enough to tap reliably and keeps the + pill from ballooning. The button itself remains keyboard-accessible. */ +.tag button { + min-height: 24px; + min-width: 24px; + line-height: 1; +} .recovery-code { display: block; @@ -137,6 +153,24 @@ nav.top { align-items: center; justify-content: space-between; margin-bottom: 1.5rem; + gap: 0.5rem; + flex-wrap: wrap; +} +nav.top > .row { + flex-wrap: wrap; + justify-content: flex-end; +} + +/* Narrow phones: drop the nav-button gap a little and let the title shrink. */ +@media (max-width: 480px) { + main { padding-top: 1rem; } + h1 { font-size: 1.4rem; } + nav.top h1 { flex: 1 1 100%; } + /* Inputs are wide by default; just make sure they don't overflow on tiny screens. */ + input[type="text"], input[type="email"], input[type="password"], + input[type="datetime-local"], input[type="search"], textarea, select { + font-size: 16px; /* prevents iOS from auto-zooming on focus */ + } } .vis-badge { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 379361b..cdd1162 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -12,7 +12,7 @@ "allowImportingTsExtensions": false, "skipLibCheck": true, "verbatimModuleSyntax": false, - "types": [] + "types": ["vite/client"] }, "include": ["src/**/*", "../shared/**/*"], "exclude": ["dist", "node_modules"] diff --git a/server/activities.ts b/server/activities.ts index 1a9b847..b118536 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -41,15 +41,24 @@ interface ActivityRow { * back to the part before the `@` in the email so accounts that haven't set * one still get something less hostile than a UUID slice. Email itself is * NOT surfaced — that's a contact identifier, not an attribution. + * + * Also returns the user's URL slug if they've opted into a public list; the + * client uses that to link the attribution to //list. Returns null + * for the slug whenever the user hasn't opted in, so the link decision is + * purely server-side. */ -function ownerDisplay(ownerId: string): string { +function ownerAttribution(ownerId: string): { display: string; username: string | null } { const row = getDb() - .prepare('SELECT display_name, email FROM users WHERE id = ?') - .get(ownerId) as { display_name: string | null; email: string } | null; - if (!row) return 'ukjent'; - if (row.display_name && row.display_name.trim()) return row.display_name; - const at = row.email.indexOf('@'); - return at > 0 ? row.email.slice(0, at) : row.email; + .prepare('SELECT display_name, email, username, public_list_enabled FROM users WHERE id = ?') + .get(ownerId) as + | { display_name: string | null; email: string; username: string | null; public_list_enabled: number | null } + | null; + if (!row) return { display: 'ukjent', username: null }; + const display = (row.display_name && row.display_name.trim()) + ? row.display_name + : (row.email.indexOf('@') > 0 ? row.email.slice(0, row.email.indexOf('@')) : row.email); + const username = row.public_list_enabled === 1 ? row.username : null; + return { display, username }; } function isModerator(userId: string): boolean { @@ -82,7 +91,9 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { } const tags = tagsFor(row.id); if (row.visibility === 'semi') { - // owner_id deliberately omitted — see SECURITY.md + // owner_id is included ONLY when the viewer IS the owner — that lets the + // client render Edit/Delete on the user's own semi rows without leaking + // attribution to anyone else. See SECURITY.md. const a: ActivitySemi = { id: row.id, visibility: 'semi', @@ -95,13 +106,16 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { created_at: row.created_at, updated_at: row.updated_at, }; + if (viewerId === row.owner_id) a.owner_id = row.owner_id; return a; } + const attrib = ownerAttribution(row.owner_id); const a: ActivityPublic = { id: row.id, visibility: 'public', owner_id: row.owner_id, - owner_display: ownerDisplay(row.owner_id), + owner_display: attrib.display, + owner_username: attrib.username, title: row.title ?? '', tags, loc_label: row.loc_label, diff --git a/server/index.ts b/server/index.ts index 85433f6..ab7b62f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,11 +28,15 @@ app.route('/api/tags', tagsRoutes); app.route('/api/users', usersRoutes); app.route('/api/feedback', feedbackRoutes); -// In production, serve the built Svelte SPA. Hono's bun static helper handles -// asset MIME types; everything else falls through to index.html for SPA routing. +// In production, serve the built Svelte SPA. The static helper is registered +// for the asset directory and for the top-level files that Vite copies from +// `frontend/public/` (manifest, icons, service worker). Unknown paths fall +// through to index.html so the SPA's path routing (//list) works. if (process.env.NODE_ENV === 'production') { app.use('/assets/*', serveStatic({ root: './frontend/dist' })); - app.get('/favicon.svg', serveStatic({ path: './frontend/dist/favicon.svg' })); + for (const file of ['favicon.svg', 'icon.svg', 'manifest.webmanifest', 'sw.js']) { + app.get(`/${file}`, serveStatic({ path: `./frontend/dist/${file}` })); + } app.get('*', serveStatic({ path: './frontend/dist/index.html' })); } diff --git a/server/users.ts b/server/users.ts index 02f9c1f..57a8814 100644 --- a/server/users.ts +++ b/server/users.ts @@ -54,6 +54,9 @@ usersRoutes.get('/:username/list', (c) => { visibility: 'public', owner_id: r.owner_id, owner_display: user.display_name?.trim() || username, + // The list itself is at //list, so we already know the slug. + // Surfacing it on each row keeps ActivityRow's rendering uniform. + owner_username: username, title: r.title ?? '', tags: tagsFor(r.id), loc_label: r.loc_label, diff --git a/shared/types.ts b/shared/types.ts index b7957a7..48e2fe3 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -115,8 +115,11 @@ export interface FeedbackEntry { export interface ActivityPublic { id: string; visibility: 'public'; - owner_id: string; // serialized for public - owner_display: string; // display_name OR derived handle (email prefix) + owner_id: string; // serialized for public + owner_display: string; // display_name OR derived handle (email prefix) + // Owner's URL slug, if they've opted into a public list. When non-null, the + // client renders the owner attribution as a link to //list. + owner_username: string | null; title: string; tags: string[]; loc_label: string | null; @@ -130,7 +133,10 @@ export interface ActivityPublic { export interface ActivitySemi { id: string; visibility: 'semi'; - // owner_id deliberately omitted — see SECURITY.md + // Set ONLY when the viewer is the owner. Lets the client surface + // Edit/Delete on the user's own semi rows without leaking attribution to + // anyone else. Stripped server-side for any other viewer; see SECURITY.md. + owner_id?: string; title: string; tags: string[]; loc_label: string | null;