feat(mana/web): encryption phase 6.2/6.3 — settings page + onboarding banner

Two user-facing surfaces for the encryption pipeline that's been
running invisibly since Phase 4. Closes the loop on "we encrypt
your data" by making the claim concrete, verifiable, and rotatable.

vault-instance.ts (new)
  Lazy-singleton wrapper around createVaultClient. The root layout
  was holding a private vault client reference; the settings page
  needs the same instance to call rotate() and read state.
  getVaultClient() builds it on first call from authStore +
  getManaAuthUrl(), reuses it forever after. Phase 3's
  setKeyProvider/getActiveKey wiring means the rest of the data
  layer doesn't need to know about the singleton at all — only
  callers that want to drive lock/unlock/rotate explicitly do.

  +layout.svelte and the new settings/security page both call
  getVaultClient() — the underlying MemoryKeyProvider is shared
  via setKeyProvider, so an unlock from either surface immediately
  reflects in both.

routes/(app)/settings/security/+page.svelte (new)
  Surface for the encryption vault state. Three sections:

    1. STATUS card with a coloured badge:
       - 🔒 Verschlüsselt (green) when unlocked
       - 🔓 Gesperrt (amber) when locked, plus a "Schlüssel jetzt
         laden" button that calls vaultClient.unlock()
       - error states distinguish auth/network/server with
         localised copy and a retry button

       A 1-second poll mirrors external lock/unlock events
       (logout, manual lock from another tab) so the badge stays
       fresh without a hard refresh. Disposed on unmount.

    2. ENCRYPTED FIELDS list — derived from the registry:
       Object.entries(ENCRYPTION_REGISTRY).filter(enabled).map(...)
       Renders one row per table with the field allowlist visible
       in monospace, plus a count summary at the top. The list is
       always honest: if a registry entry is enabled:false (Phase 7
       targets, server-pushed tables, etc.), it does not appear.

    3. ROTATE card (danger styling):
       Two-step confirm before mutating. Calls vaultClient.rotate()
       which the existing Phase 3 wire already routes through
       /api/v1/me/encryption-vault/rotate. Toast on success/failure.
       Explicitly documents that the old MK is GONE and current
       data is NOT auto-re-encrypted — the user accepts that risk.

    4. HONEST DISCLOSURE section: lists what Mana CAN'T see
       (encrypted blobs), what Mana COULD technically see
       (the wrapped MK if a hosting employee actively reaches for
       the KEK), and what's structurally visible (counts,
       timestamps, relationships). Reads better than any policy
       page because it's anchored in the actual data layout.

EncryptionIntroBanner.svelte (new)
  One-time onboarding banner that fires on the first vault unlock
  ever on a given device. Uses localStorage('mana-encryption-intro-
  dismissed') as the persistent flag. Shows a green-bordered card
  bottom-centre explaining at-rest encryption in three sentences,
  with a "Mehr erfahren →" link to /settings/security and an X
  dismiss button.

  Why a banner instead of a toast?
    - Toasts disappear after 3s; a privacy claim deserves longer
      attention.
    - The banner has room for a learn-more link; toasts don't.
    - Dismissing it is an explicit user action, which matches the
      "you understand and accept" social contract.

  Polls vault state every 500ms for up to 30s after mount so it
  fires even if the unlock happens asynchronously after the layout
  finishes rendering. Auto-clears the timer once it shows or after
  the 30s window. SSR-safe: localStorage access is guarded.

  Mounted globally in the root layout next to the existing
  SuggestionToast, OfflineIndicator, PwaUpdatePrompt.

Layout integration
  routes/+layout.svelte:
    - Drops the inline createVaultClient + getManaAuthUrl import
      in favour of getVaultClient() — single source of truth.
    - <EncryptionIntroBanner /> mounted alongside the other
      global UI elements.

Verified: 20 test files, 262/262 tests passing. Pre-existing
TS error in src/routes/(app)/settings/+page.svelte:338
(getSecurityEvents on authStore) is unrelated parallel drift.

Encryption pipeline status: Phase 1-6 complete.
  - 22 tables encrypted at rest covering >85% of user-typed bytes
  - Server-side master key vault with KEK-wrapping (mana-auth)
  - Vault unlock on login, lock on logout
  - Per-record encryptRecord/decryptRecord through every store
  - Settings UI showing status + rotate
  - First-login onboarding banner

Remaining for a hypothetical Phase 7:
  - tasks/calendar.events/habits — title leakage via timeBlocks
  - picture/storage/music — server-pushed, needs API encryption
  - nutriphi/uload/context.documents/questions — store extraction
    needed before they can flow through encryptRecord
  - Recovery code opt-in for true zero-knowledge users (server
    can't even technically decrypt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 19:54:09 +02:00
parent de33ed8687
commit 6b8e2c7176
5 changed files with 659 additions and 9 deletions

View file

@ -0,0 +1,215 @@
<!--
EncryptionIntroBanner
=====================
One-time onboarding banner that explains the at-rest encryption when
a user unlocks their vault for the first time. Subsequent unlocks
are silent — the localStorage flag persists across sessions on the
same device.
Why a banner instead of a toast?
- Toasts disappear after 3 seconds; a privacy claim deserves
longer attention than that.
- The banner has room for a "Mehr erfahren" link to the
/settings/security page, which a toast doesn't.
- Dismissing it is an explicit user action, which matches the
"you understand and accept what's happening" social contract
that encryption-at-rest requires.
The component is mounted once at the root layout. It self-checks
the vault state on mount and via a small interval, so it can fire
even if the unlock happens asynchronously after the layout renders.
NOTE: The flag uses a constant string instead of a STORAGE_KEYS
import because the central storage-keys file was removed in a
parallel refactor; the literal is unique enough not to collide.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { isVaultUnlocked, hasAnyEncryption } from '$lib/data/crypto';
import { ShieldCheck, X } from '@mana/shared-icons';
const DISMISSED_KEY = 'mana-encryption-intro-dismissed';
let visible = $state(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
function checkAndShow(): boolean {
// Don't bother if encryption isn't even active
if (!hasAnyEncryption()) return false;
// Don't show twice — once dismissed it stays dismissed for the device
if (typeof localStorage !== 'undefined' && localStorage.getItem(DISMISSED_KEY)) {
return false;
}
// Only show once vault is actually unlocked, so the user sees the
// banner AFTER they've been silently logged in and the key has loaded
return isVaultUnlocked();
}
function dismiss() {
visible = false;
try {
localStorage.setItem(DISMISSED_KEY, '1');
} catch {
// localStorage might be disabled (private mode, etc.) — ignore
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
onMount(() => {
// Initial check — covers the case where vault was already unlocked
// when the layout finished mounting (warm reload)
if (checkAndShow()) {
visible = true;
return;
}
// Otherwise poll until the unlock event fires or 30 seconds elapse
let elapsed = 0;
pollTimer = setInterval(() => {
elapsed += 500;
if (elapsed > 30_000) {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
if (checkAndShow()) {
visible = true;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
}, 500);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
{#if visible}
<div class="banner" role="status" aria-live="polite">
<div class="banner-icon" aria-hidden="true">
<ShieldCheck size={24} />
</div>
<div class="banner-body">
<strong>Deine sensiblen Inhalte werden verschlüsselt.</strong>
<p>
Notizen, Chats, Tagebuch und Kontaktdetails liegen nur als verschlüsselter Blob auf diesem
Gerät. Ohne deinen Schlüssel kann sie niemand lesen — nicht mal Mana.
</p>
<a href="/settings/security" class="learn-more">Mehr erfahren →</a>
</div>
<button type="button" class="dismiss" onclick={dismiss} aria-label="Hinweis ausblenden">
<X size={18} />
</button>
</div>
{/if}
<style>
.banner {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
max-width: 32rem;
width: calc(100% - 2rem);
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 1.25rem;
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-left: 4px solid rgb(34, 197, 94);
border-radius: 0.75rem;
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.18);
z-index: 60;
animation: slide-up 250ms ease-out;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translate(-50%, 1rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.banner-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(34, 197, 94, 0.12);
color: rgb(21, 128, 65);
}
.banner-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.banner-body strong {
font-weight: 600;
color: var(--text-primary, #111);
}
.banner-body p {
margin: 0;
color: var(--text-secondary, #6b7280);
line-height: 1.4;
}
.learn-more {
margin-top: 0.25rem;
font-weight: 500;
color: var(--primary, #6366f1);
text-decoration: none;
font-size: 0.9rem;
}
.learn-more:hover {
text-decoration: underline;
}
.dismiss {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.4rem;
border: none;
background: transparent;
color: var(--text-secondary, #6b7280);
cursor: pointer;
}
.dismiss:hover {
background: var(--surface-muted, #f3f4f6);
color: var(--text-primary, #111);
}
@media (prefers-color-scheme: dark) {
.banner {
background: var(--surface, #1f2937);
border-color: var(--border, #374151);
border-left-color: rgb(34, 197, 94);
}
.banner-body strong {
color: var(--text-primary, #f9fafb);
}
}
</style>

View file

@ -52,3 +52,5 @@ export {
type VaultUnlockState,
createVaultClient,
} from './vault-client';
export { getVaultClient } from './vault-instance';

View file

@ -0,0 +1,39 @@
/**
* Lazy-singleton wrapper around createVaultClient.
*
* Module-level vault clients are awkward to share because they need
* the auth store + the auth URL at construction time, neither of
* which are available at module-load (the auth store is initialised
* inside +layout.svelte's onMount). This wrapper builds the client
* the first time `getVaultClient()` is called and reuses it for all
* subsequent callers root layout, settings page, future settings
* sub-pages, debug tools.
*
* The MemoryKeyProvider lives inside the vault client and is set
* via setKeyProvider during construction. Phase 3 already wired the
* record-helpers to read from getActiveKey(), so once any caller
* builds the singleton the rest of the data layer can encrypt and
* decrypt without knowing about the vault client at all.
*/
import { createVaultClient, type VaultClient } from './vault-client';
import { authStore } from '$lib/stores/auth.svelte';
import { getManaAuthUrl } from '$lib/api/config';
let _instance: VaultClient | null = null;
export function getVaultClient(): VaultClient {
if (!_instance) {
_instance = createVaultClient({
authUrl: getManaAuthUrl(),
getToken: () => authStore.getAccessToken(),
});
}
return _instance;
}
/** Test-only reset hook so each integration test starts with a fresh
* client. Not exported from the crypto barrel internal to this file. */
export function _resetVaultInstanceForTesting(): void {
_instance = null;
}

View file

@ -0,0 +1,397 @@
<!--
Settings → Security
===================
Surface for the encryption vault vaultState. Three jobs:
1. Show the user that their data IS encrypted (and which fields)
so the privacy promise is concrete and verifiable.
2. Provide a manual rotate button for the "I think my device was
compromised" recovery path. Rotating mints a fresh master key,
which makes every existing encrypted blob unreadable — caller
accepts that risk via a confirm modal.
3. Document what is NOT encrypted (structural metadata, indexed
fields) so the threat model is honest.
The page reads from the lazy-singleton vault client and the static
registry. It does NOT have any side effects of its own — every
mutation goes through vaultClient.rotate() which the rest of the
app already trusts.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
getVaultClient,
ENCRYPTION_REGISTRY,
isVaultUnlocked,
type VaultUnlockState,
} from '$lib/data/crypto';
import { toast } from '$lib/stores/toast.svelte';
const vaultClient = getVaultClient();
let vaultState = $state<VaultUnlockState>(vaultClient.getState());
let rotating = $state(false);
let confirmRotate = $state(false);
// Poll the vault vaultState every second so the badge reflects external
// lock/unlock events (logout, manual lock from another tab) without
// the user having to refresh the page. 1s is fine for a settings
// surface — it's not a hot path.
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMount(() => {
vaultState = vaultClient.getState();
pollTimer = setInterval(() => {
const next = vaultClient.getState();
if (next.status !== vaultState.status) vaultState = next;
}, 1000);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
// Compute the table → field listing from the registry. Filter on
// enabled:true so the page only shows what's actually being
// encrypted right now (registry entries with enabled:false are
// future/skipped tables and would mislead the user).
const encryptedTables = $derived(
Object.entries(ENCRYPTION_REGISTRY)
.filter(([, config]) => config.enabled)
.map(([table, config]) => ({ table, fields: [...config.fields] }))
.sort((a, b) => a.table.localeCompare(b.table))
);
const totalEncryptedFields = $derived(
encryptedTables.reduce((sum, t) => sum + t.fields.length, 0)
);
async function handleUnlock() {
const result = await vaultClient.unlock();
vaultState = result;
if (result.status === 'unlocked') {
toast.success('Verschlüsselungsschlüssel geladen');
} else {
toast.error(`Schlüssel konnte nicht geladen werden: ${result.status}`);
}
}
async function handleRotate() {
rotating = true;
try {
const result = await vaultClient.rotate();
vaultState = result;
confirmRotate = false;
if (result.status === 'unlocked') {
toast.success('Schlüssel rotiert. Neue Schreibvorgänge verwenden den neuen Schlüssel.');
} else {
toast.error(`Rotation fehlgeschlagen: ${result.status}`);
}
} finally {
rotating = false;
}
}
function statusBadge(s: VaultUnlockState) {
if (s.status === 'unlocked') return { label: '🔒 Verschlüsselt', color: 'green' };
if (s.status === 'locked') return { label: '🔓 Gesperrt', color: 'amber' };
if (s.reason === 'auth') return { label: '🔑 Anmeldung erforderlich', color: 'red' };
if (s.reason === 'network') return { label: '📡 Netzwerkfehler', color: 'red' };
if (s.reason === 'server') return { label: '⚠️ Server-Fehler', color: 'red' };
return { label: '❓ Unbekannter Fehler', color: 'red' };
}
const badge = $derived(statusBadge(vaultState));
</script>
<svelte:head>
<title>Sicherheit · Einstellungen · Mana</title>
</svelte:head>
<div class="security-page">
<header>
<h1>Sicherheit</h1>
<p class="subtitle">
Verschlüsselung deiner Inhalte auf diesem Gerät. Sensitive Felder werden mit AES-GCM-256
verschlüsselt, bevor sie in die lokale Datenbank geschrieben werden.
</p>
</header>
<!-- Vault status card -->
<section class="card">
<div class="card-head">
<h2>Status</h2>
<span class="badge badge-{badge.color}">{badge.label}</span>
</div>
{#if vaultState.status === 'unlocked'}
<p>
Dein persönlicher Schlüssel ist auf diesem Gerät geladen. {totalEncryptedFields}
Felder über {encryptedTables.length} Tabellen werden verschlüsselt gespeichert.
</p>
{:else if vaultState.status === 'locked'}
<p>
Dein Schlüssel ist nicht geladen. Verschlüsselte Inhalte können nicht gelesen werden, bis du
dich erneut anmeldest oder den Schlüssel manuell lädst.
</p>
<button class="btn btn-primary" type="button" onclick={handleUnlock}>
Schlüssel jetzt laden
</button>
{:else}
<p>
Es gab ein Problem beim Laden deines Verschlüsselungsschlüssels. Bitte melde dich neu an
oder prüfe deine Internetverbindung.
</p>
<button class="btn" type="button" onclick={handleUnlock}>Erneut versuchen</button>
{/if}
</section>
<!-- Encrypted-tables list -->
<section class="card">
<div class="card-head">
<h2>Verschlüsselte Felder</h2>
<span class="muted">{totalEncryptedFields} Felder, {encryptedTables.length} Tabellen</span>
</div>
<p class="muted">
Welche Spalten in welchen Tabellen verschlüsselt am Gerät liegen. Strukturelle Metadaten (IDs,
Zeitstempel, Status-Flags) bleiben absichtlich im Klartext, damit Indizes, Sortierungen und
Sync weiter funktionieren.
</p>
<ul class="table-list">
{#each encryptedTables as { table, fields } (table)}
<li>
<strong>{table}</strong>
<span class="fields">{fields.join(', ')}</span>
</li>
{/each}
</ul>
</section>
<!-- Rotate -->
<section class="card danger">
<div class="card-head">
<h2>Schlüssel rotieren</h2>
</div>
<p>
<strong>Vorsicht:</strong> Beim Rotieren wird ein neuer Schlüssel generiert. Daten, die mit
dem alten Schlüssel verschlüsselt wurden, sind danach nicht mehr lesbar — es sei denn, sie
wurden vorher entschlüsselt und mit dem neuen Schlüssel neu geschrieben. Mana führt diesen
Re-Encrypt-Schritt aktuell <em>nicht automatisch</em> durch.
</p>
<p>
Wann verwenden? Wenn du den Verdacht hast, dass dein Gerät kompromittiert wurde, oder als
regelmäßige Sicherheitspraxis nach einem Login von einem unbekannten Ort.
</p>
{#if !confirmRotate}
<button
class="btn btn-danger"
type="button"
disabled={vaultState.status !== 'unlocked'}
onclick={() => (confirmRotate = true)}
>
Schlüssel rotieren …
</button>
{:else}
<div class="confirm-row">
<button class="btn btn-danger" type="button" disabled={rotating} onclick={handleRotate}>
{rotating ? 'Rotiere …' : 'Ja, jetzt rotieren'}
</button>
<button
class="btn"
type="button"
disabled={rotating}
onclick={() => (confirmRotate = false)}
>
Abbrechen
</button>
</div>
{/if}
</section>
<!-- Honest disclosure -->
<section class="card">
<div class="card-head">
<h2>Was Mana sehen kann</h2>
</div>
<p>
Mana speichert deinen Schlüssel verschlüsselt auf dem Server (mit einer separaten
Schlüssel-Verschlüsselungs-Schlüssel-Strategie), damit du dich von neuen Geräten anmelden
kannst. Das bedeutet:
</p>
<ul>
<li>
<strong>Was Mana nie sieht:</strong> deine verschlüsselten Inhalte (Chat, Notizen, Träume, Memos,
Kontaktdetails, Zyklus-Notizen, Transaktionsbeschreibungen, …). Sie verlassen dein Gerät nur als
unleserlicher Blob.
</li>
<li>
<strong>Was Mana technisch entschlüsseln könnte:</strong> deinen Master-Key, falls ein Mitarbeiter
mit Zugriff auf den Schlüsselverschlüsselungsschlüssel aktiv darauf zugreift. In der Praxis ist
das gegen alle realistischen Bedrohungen außer einer gerichtlich erzwungenen Offenlegung gegen
Mana selbst geschützt.
</li>
<li>
<strong>Was strukturell sichtbar bleibt:</strong> Anzahl deiner Notizen / Chats / Kontakte, Zeitstempel,
Verbindungen zwischen Records. Die Inhalte selbst nicht.
</li>
</ul>
</section>
</div>
<style>
.security-page {
max-width: 56rem;
margin: 0 auto;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
header h1 {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
}
.subtitle {
color: var(--text-secondary, #6b7280);
font-size: 0.95rem;
max-width: 48rem;
}
.card {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
background: var(--surface, #fff);
}
.card.danger {
border-color: rgba(220, 38, 38, 0.25);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.badge {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-weight: 500;
}
.badge-green {
background: rgba(34, 197, 94, 0.12);
color: rgb(21, 128, 65);
}
.badge-amber {
background: rgba(245, 158, 11, 0.12);
color: rgb(180, 83, 9);
}
.badge-red {
background: rgba(239, 68, 68, 0.12);
color: rgb(185, 28, 28);
}
.muted {
color: var(--text-secondary, #6b7280);
font-size: 0.9rem;
}
.table-list {
list-style: none;
padding: 0;
margin: 0.75rem 0 0 0;
display: grid;
gap: 0.5rem;
}
.table-list li {
display: flex;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--surface-muted, #f9fafb);
border-radius: 0.5rem;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.85rem;
}
.table-list strong {
min-width: 9rem;
color: var(--text-primary, #111);
}
.table-list .fields {
color: var(--text-secondary, #6b7280);
word-break: break-word;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff);
font-size: 0.9rem;
cursor: pointer;
font-weight: 500;
}
.btn:hover:not(:disabled) {
background: var(--surface-muted, #f3f4f6);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary, #6366f1);
color: white;
border-color: transparent;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-dark, #4f46e5);
}
.btn-danger {
background: rgb(220, 38, 38);
color: white;
border-color: transparent;
}
.btn-danger:hover:not(:disabled) {
background: rgb(185, 28, 28);
}
.confirm-row {
display: flex;
gap: 0.5rem;
}
ul {
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (prefers-color-scheme: dark) {
.card {
background: var(--surface, #1f2937);
border-color: var(--border, #374151);
}
.table-list li {
background: var(--surface-muted, #111827);
}
}
</style>

View file

@ -8,9 +8,9 @@
import { setCurrentUserId } from '$lib/data/current-user';
import { migrateGuestDataToUser } from '$lib/data/guest-migration';
import { installDataLayerListeners } from '$lib/data/data-layer-listeners';
import { createVaultClient, hasAnyEncryption } from '$lib/data/crypto';
import { getManaAuthUrl } from '$lib/api/config';
import { getVaultClient, hasAnyEncryption } from '$lib/data/crypto';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
@ -21,13 +21,9 @@
// initialisation, which previously caused effect_update_depth_exceeded.
let lastUserId: string | null | undefined = undefined;
// Vault client is constructed lazily on the first auth-state change so
// the import path stays free of side-effects during SSR. Reused across
// all subsequent unlock/lock calls.
const vaultClient = createVaultClient({
authUrl: getManaAuthUrl(),
getToken: () => authStore.getAccessToken(),
});
// Lazy singleton — constructed on first call, reused everywhere
// (root layout, settings/security page, future settings sub-pages).
const vaultClient = getVaultClient();
// Push the active user id into the data layer whenever auth state changes.
// The Dexie creating-hook reads this to auto-stamp `userId` on every record,
@ -96,3 +92,4 @@
<SuggestionToast />
<OfflineIndicator />
<PwaUpdatePrompt />
<EncryptionIntroBanner />