Public landing, owner-list links, owner-conditional semi, PWA + mobile

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 /<owner_username>/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 /<username>/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 /<username>/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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 12:57:59 +02:00
commit f0b4d735b5
15 changed files with 317 additions and 54 deletions

View file

@ -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

View file

@ -2,8 +2,15 @@
<html lang="nb">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- viewport-fit=cover lets us paint under iOS notches; the safe-area
insets in styles.css take care of keeping content readable. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#1f6feb" />
<meta name="description" content="Ende-til-ende-kryptert liste over vinteraktiviteter." />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Vinterliste</title>
</head>
<body>

54
frontend/public/icon.svg Normal file
View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Vinterliste">
<!-- Background tile, large enough to satisfy `maskable` safe zone (centre 40% padding). -->
<rect width="512" height="512" rx="96" fill="#1f6feb"/>
<!-- Stylised snowflake — 6 arms with side branches, drawn from the centre. -->
<g stroke="#ffffff" stroke-width="22" stroke-linecap="round" fill="none">
<!-- Six main arms, rotated 60° each. -->
<g transform="translate(256 256)">
<g>
<line x1="0" y1="0" x2="0" y2="-150"/>
<line x1="0" y1="-90" x2="-30" y2="-120"/>
<line x1="0" y1="-90" x2="30" y2="-120"/>
<line x1="0" y1="-50" x2="-22" y2="-72"/>
<line x1="0" y1="-50" x2="22" y2="-72"/>
</g>
<g transform="rotate(60)">
<line x1="0" y1="0" x2="0" y2="-150"/>
<line x1="0" y1="-90" x2="-30" y2="-120"/>
<line x1="0" y1="-90" x2="30" y2="-120"/>
<line x1="0" y1="-50" x2="-22" y2="-72"/>
<line x1="0" y1="-50" x2="22" y2="-72"/>
</g>
<g transform="rotate(120)">
<line x1="0" y1="0" x2="0" y2="-150"/>
<line x1="0" y1="-90" x2="-30" y2="-120"/>
<line x1="0" y1="-90" x2="30" y2="-120"/>
<line x1="0" y1="-50" x2="-22" y2="-72"/>
<line x1="0" y1="-50" x2="22" y2="-72"/>
</g>
<g transform="rotate(180)">
<line x1="0" y1="0" x2="0" y2="-150"/>
<line x1="0" y1="-90" x2="-30" y2="-120"/>
<line x1="0" y1="-90" x2="30" y2="-120"/>
<line x1="0" y1="-50" x2="-22" y2="-72"/>
<line x1="0" y1="-50" x2="22" y2="-72"/>
</g>
<g transform="rotate(240)">
<line x1="0" y1="0" x2="0" y2="-150"/>
<line x1="0" y1="-90" x2="-30" y2="-120"/>
<line x1="0" y1="-90" x2="30" y2="-120"/>
<line x1="0" y1="-50" x2="-22" y2="-72"/>
<line x1="0" y1="-50" x2="22" y2="-72"/>
</g>
<g transform="rotate(300)">
<line x1="0" y1="0" x2="0" y2="-150"/>
<line x1="0" y1="-90" x2="-30" y2="-120"/>
<line x1="0" y1="-90" x2="30" y2="-120"/>
<line x1="0" y1="-50" x2="-22" y2="-72"/>
<line x1="0" y1="-50" x2="22" y2="-72"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -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"
}
]
}

73
frontend/public/sw.js Normal file
View file

@ -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');
});
}),
);
});

View file

@ -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 @@
<main>
<nav class="top">
<h1 style="margin: 0;">Vinterliste</h1>
{#if session.user && view !== 'public-list'}
{#if view !== 'public-list'}
<div class="row">
<button type="button" onclick={() => (view = 'feedback')}
aria-label="Send tilbakemelding">
Tilbakemelding
</button>
<button type="button" onclick={() => (view = 'profile')}
aria-label="Profil">
{session.user.display_name?.trim() || session.user.email}
</button>
<button onclick={onLogout}>Logg ut</button>
{#if session.user}
<button type="button" onclick={() => (view = 'feedback')}
aria-label="Send tilbakemelding">
Tilbakemelding
</button>
<button type="button" onclick={() => (view = 'profile')}
aria-label="Profil">
{session.user.display_name?.trim() || session.user.email}
</button>
<button onclick={onLogout}>Logg ut</button>
{:else if view !== 'login' && view !== 'signup' && view !== 'recovery'}
<button class="primary" type="button" onclick={() => (view = 'login')}>
Logg inn
</button>
{/if}
</div>
{/if}
</nav>

View file

@ -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}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
{#if activity.visibility === 'public'}
<p class="muted" style="font-size: 0.8rem;">Lagt til av {activity.owner_display}</p>
<p class="muted" style="font-size: 0.8rem;">
Lagt til av
{#if activity.owner_username}
<a href={`/${activity.owner_username}/list`}>{activity.owner_display}</a>
{:else}
{activity.owner_display}
{/if}
</p>
{/if}
{/if}

View file

@ -93,12 +93,19 @@
<section aria-label="Aktiviteter">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
<p class="muted" style="margin: 0;">
Velkommen, {session.user?.display_name?.trim() || session.user?.email}.
Her er aktivitetene dine for vinteren.
</p>
{#if !showForm && !editing}
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
{#if session.user}
<p class="muted" style="margin: 0;">
Velkommen, {session.user.display_name?.trim() || session.user.email}.
Her er aktivitetene dine for vinteren.
</p>
{#if !showForm && !editing}
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
{/if}
{:else}
<p class="muted" style="margin: 0;">
Offentlige og halv-offentlige aktiviteter. Logg inn for å legge til
dine egne — privat eller offentlig.
</p>
{/if}
</div>

View file

@ -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.
});
});
}

View file

@ -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 {

View file

@ -12,7 +12,7 @@
"allowImportingTsExtensions": false,
"skipLibCheck": true,
"verbatimModuleSyntax": false,
"types": []
"types": ["vite/client"]
},
"include": ["src/**/*", "../shared/**/*"],
"exclude": ["dist", "node_modules"]

View file

@ -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 /<username>/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,

View file

@ -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 (/<username>/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' }));
}

View file

@ -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 /<username>/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,

View file

@ -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 /<owner_username>/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;