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

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