feat(auth): UnlockBanner so post-reload DEK loss is recoverable
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
This commit is contained in:
parent
d29e1fd3d5
commit
5e5bf92afb
5 changed files with 105 additions and 3 deletions
|
|
@ -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}
|
||||
</nav>
|
||||
|
||||
<UnlockBanner />
|
||||
|
||||
{#if view === 'loading'}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if view === 'public-list'}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@
|
|||
<span class="vis-badge private">Privat</span>
|
||||
</h3>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="muted">Dekrypterer …</p>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
75
frontend/src/components/UnlockBanner.svelte
Normal file
75
frontend/src/components/UnlockBanner.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Persistent banner shown when a user has a server session but no DEK.
|
||||
*
|
||||
* Happens after every full-page reload (the DEK lives only in memory —
|
||||
* see lib/session.svelte.ts) and means private rows can't decrypt and
|
||||
* new private rows can't be created. Without this banner the user
|
||||
* encountered confusing failure modes (a "Logg inn på nytt" line under
|
||||
* each private row, a raw `not_logged_in` error on save).
|
||||
*
|
||||
* The form runs the regular login() flow under the hood — that
|
||||
* derives the KEK from the password and salt and unwraps the existing
|
||||
* wrapped_dek_pw, so private ciphertexts from before the reload stay
|
||||
* readable. The cookie is replaced as a side effect; that's fine.
|
||||
*
|
||||
* Hidden until both: user IS logged in, DEK IS missing. Renders
|
||||
* nothing otherwise — App.svelte mounts it unconditionally.
|
||||
*/
|
||||
import { session } from '../lib/session.svelte';
|
||||
import { login } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
let password = $state('');
|
||||
let busy = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Show the banner whenever the user is "logged in but locked." A
|
||||
// dismissed flag would only hide it temporarily — the next reload puts
|
||||
// them right back here, and the message is brief enough that it doesn't
|
||||
// pay to make it dismissable.
|
||||
const visible = $derived(!!session.user && !session.dek);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!session.user || busy) return;
|
||||
error = null;
|
||||
busy = true;
|
||||
try {
|
||||
await login(session.user.email, password);
|
||||
password = '';
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError && err.status === 401
|
||||
? 'Feil passord. Prøv igjen.'
|
||||
: 'Klarte ikke å låse opp. Prøv igjen om litt.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<aside class="banner" role="region" aria-label="Lås opp privat innhold">
|
||||
<form onsubmit={submit} class="row" style="gap: 0.5rem; align-items: center; margin: 0;">
|
||||
<span style="flex: 1 1 auto;">
|
||||
🔒 Privat innhold er låst etter sideoppdatering.
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={password}
|
||||
placeholder="Passord"
|
||||
aria-label="Passord"
|
||||
required
|
||||
minlength="12"
|
||||
style="flex: 0 1 14rem; min-width: 0;"
|
||||
/>
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Låser opp …' : 'Lås opp'}
|
||||
</button>
|
||||
</form>
|
||||
{#if error}
|
||||
<p class="error" role="alert" style="margin: 0.4rem 0 0;">{error}</p>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
Loading…
Add table
Add a link
Reference in a new issue