From 5e5bf92afb7a2bc465a33e6584e193d778fd12fb Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 22:14:45 +0200 Subject: [PATCH] feat(auth): UnlockBanner so post-reload DEK loss is recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a page reload the SPA rehydrates session.user from /me but the DEK lives only in memory and is intentionally gone. Previously this manifested as: - "Logg inn på nytt med passordet ditt" line under each private row (vague — full re-login replaces the cookie too) - A raw "not_logged_in" Error.message on saving a private activity - Export silently dropping every private row from the file New UnlockBanner.svelte mounts unconditionally in App.svelte and renders only when session.user is set but session.dek is null. It takes the password inline and runs the existing login() flow — same challenge/derive/unwrap path — so the existing wrapped DEK is recovered and all the user's private ciphertexts stay readable. Replacing the cookie as a side effect is fine. Polished a few other DEK-missing paths: - ActivityRow's private branch now says "Lås opp øverst på siden" instead of "Logg inn på nytt" - ActivityForm has a pre-flight check before submit + a friendly catch for the internal dek_missing sentinel - Profile's "Last ned eksport" refuses early with a "lås opp" pointer instead of producing a quietly truncated export --- frontend/src/App.svelte | 3 + frontend/src/components/ActivityForm.svelte | 21 +++++- frontend/src/components/ActivityRow.svelte | 2 +- frontend/src/components/Profile.svelte | 7 ++ frontend/src/components/UnlockBanner.svelte | 75 +++++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/UnlockBanner.svelte diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5dc5584..94ff430 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -14,6 +14,7 @@ import PublicList from './components/PublicList.svelte'; import Admin from './components/Admin.svelte'; import ModerateTags from './components/ModerateTags.svelte'; + import UnlockBanner from './components/UnlockBanner.svelte'; import ActivityPermalink from './components/ActivityPermalink.svelte'; import Personvern from './components/Personvern.svelte'; import TagPage from './components/TagPage.svelte'; @@ -263,6 +264,8 @@ {/if} + + {#if view === 'loading'}

Laster …

{:else if view === 'public-list'} diff --git a/frontend/src/components/ActivityForm.svelte b/frontend/src/components/ActivityForm.svelte index a64d8d3..69b5942 100644 --- a/frontend/src/components/ActivityForm.svelte +++ b/frontend/src/components/ActivityForm.svelte @@ -107,7 +107,12 @@ */ function buildBody(): CreateActivityRequest { if (visibility === 'private') { - if (!session.dek) throw new Error('not_logged_in'); + if (!session.dek) { + // Caller (submit) catches this and surfaces a useful message + // pointing at the unlock banner. The error code is a sentinel, + // not user-facing text. + throw new Error('dek_missing'); + } const payload: PrivatePayload = { title: title.trim(), tags, @@ -166,6 +171,14 @@ error = 'Tittel er påkrevd.'; return; } + // Pre-flight: if the user picked Privat but the DEK is missing + // (post-reload state), point them at the unlock banner instead of + // letting them fill out a form they can't submit. Same check in + // buildBody catches direct mis-calls. + if (visibility === 'private' && !session.dek) { + error = 'Privat innhold er låst etter sideoppdatering. Lås opp øverst på siden, så kan du lagre.'; + return; + } busy = true; try { const body = buildBody(); @@ -175,7 +188,11 @@ await syncPrivateTagIndex(); onCreated(result); } catch (err) { - error = err instanceof Error ? err.message : String(err); + if (err instanceof Error && err.message === 'dek_missing') { + error = 'Privat innhold er låst etter sideoppdatering. Lås opp øverst på siden, så kan du lagre.'; + } else { + error = err instanceof Error ? err.message : String(err); + } } finally { busy = false; } diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index d481812..c619342 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -316,7 +316,7 @@ Privat

- Innholdet er kryptert. Logg inn på nytt med passordet ditt for å vise det. + Innholdet er kryptert. Lås opp øverst på siden for å vise det.

{:else}

Dekrypterer …

diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 447a2a3..7d78462 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -204,6 +204,13 @@ exporting = true; exportError = null; exportSize = null; + // Without the DEK we'd silently drop every private row from the + // export. Surface that instead of producing a confusingly small file. + if (!session.dek) { + exportError = 'Privat innhold er låst etter sideoppdatering. Lås opp øverst på siden, så får du med alt.'; + exporting = false; + return; + } try { exportSize = await downloadExport(); } catch { diff --git a/frontend/src/components/UnlockBanner.svelte b/frontend/src/components/UnlockBanner.svelte new file mode 100644 index 0000000..a252fa8 --- /dev/null +++ b/frontend/src/components/UnlockBanner.svelte @@ -0,0 +1,75 @@ + + +{#if visible} + +{/if}