mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 02:19:41 +02:00
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:
parent
de33ed8687
commit
6b8e2c7176
5 changed files with 659 additions and 9 deletions
|
|
@ -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>
|
||||
|
|
@ -52,3 +52,5 @@ export {
|
|||
type VaultUnlockState,
|
||||
createVaultClient,
|
||||
} from './vault-client';
|
||||
|
||||
export { getVaultClient } from './vault-instance';
|
||||
|
|
|
|||
39
apps/mana/apps/web/src/lib/data/crypto/vault-instance.ts
Normal file
39
apps/mana/apps/web/src/lib/data/crypto/vault-instance.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue