feat(visibility): M7 — /settings privacy overview + kill-switch

Adds a "Privatsphäre" tab to /settings (next to Sicherheit) that lists
every record currently flipped to 'public' or 'unlisted' across all
15 visibility-aware tables, with one-click downgrade per record and a
global "Alle auf privat zurücksetzen" kill-switch.

Architecture:
- `lib/data/privacy/exposed-records.ts` is the single registry of
  visibility-aware tables. For each: the collection name, encryption
  flag, title-extraction strategy, deep-link, and a dynamic-import
  fixer that calls the module's setVisibility (so unlisted records
  properly revoke their server snapshots, not just flip the field).
- `PrivacySection.svelte` subscribes via Dexie liveQuery so flips
  from any module DetailView surface here immediately.
- Kill-switch iterates the same fixers — best-effort sweep that
  logs failures but doesn't abort. Toast reports flipped/failed.

Two error/empty states wired:
- "Aktuell ist nichts öffentlich" reassures the privacy-conscious
  user that their footprint is zero.
- Per-record "Privat" button is disabled while busy so multi-clicks
  don't race.

Adding a new visibility-aware module: append one entry to TABLES in
exposed-records.ts. The Section auto-renders it.

Note: 2 unrelated svelte-check errors in
stores/workbench-scenes.svelte.ts (ensureSeedScene) from a parallel
session. Not introduced here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 14:24:59 +02:00
parent c73f93ff12
commit e0ec7fe33f
4 changed files with 732 additions and 1 deletions

View file

@ -6,7 +6,7 @@
import type { Component } from 'svelte';
import { Gear, Robot, ShieldCheck, Cloud, Tag } from '@mana/shared-icons';
export type CategoryId = 'general' | 'ai' | 'security' | 'data' | 'tag-presets';
export type CategoryId = 'general' | 'ai' | 'security' | 'privacy' | 'data' | 'tag-presets';
export interface Category {
id: CategoryId;
@ -39,6 +39,13 @@ export const categories: Category[] = [
icon: ShieldCheck,
anchors: ['passkeys', 'sessions', 'two-factor', 'vault', 'security-log'],
},
{
id: 'privacy',
label: 'Privatsphäre',
description: 'Was ist gerade öffentlich oder per Link geteilt — mit Kill-Switch.',
icon: ShieldCheck,
anchors: ['privacy'],
},
{
id: 'data',
label: 'Daten & Sync',
@ -152,6 +159,20 @@ export const searchIndex: SearchEntry[] = [
anchor: 'security-log',
},
// Privacy
{
label: 'Privatsphäre-Übersicht',
keywords: ['public', 'öffentlich', 'unlisted', 'teilen', 'sharing', 'link'],
category: 'privacy',
anchor: 'privacy',
},
{
label: 'Alle auf privat zurücksetzen',
keywords: ['kill-switch', 'kill switch', 'reset', 'privat', 'widerrufen'],
category: 'privacy',
anchor: 'privacy',
},
// Data
{
label: 'Cloud Sync',

View file

@ -0,0 +1,369 @@
<!--
Privacy section — single overview of every record currently flipped
to 'public' or 'unlisted', with one-click downgrade per record and
a global kill-switch.
Owns no state beyond what's on screen — the source of truth lives
in each module's store. Re-fetches from Dexie via liveQuery so a
flip from any other UI surface (module DetailViews) reflects here
immediately without the user reloading.
-->
<script lang="ts">
import { ShieldCheck, Globe, Link as LinkIcon } from '@mana/shared-icons';
import { liveQuery } from 'dexie';
import SettingsPanel from '../SettingsPanel.svelte';
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import {
listExposedRecords,
resetAllExposedToSpace,
setRecordVisibility,
type ExposedRecord,
} from '$lib/data/privacy/exposed-records';
let exposed = $state<ExposedRecord[]>([]);
let loading = $state(true);
let busyKey = $state<string | null>(null);
let confirmKill = $state(false);
let killing = $state(false);
// liveQuery refires whenever any of the underlying tables change —
// flipping a record from a module DetailView immediately updates
// this list without the user needing to reload.
$effect(() => {
const sub = liveQuery(() => listExposedRecords()).subscribe({
next: (val) => {
exposed = val;
loading = false;
},
error: (err) => {
console.error('[privacy] liveQuery error', err);
loading = false;
},
});
return () => sub.unsubscribe();
});
const publicRecords = $derived(exposed.filter((r) => r.visibility === 'public'));
const unlistedRecords = $derived(exposed.filter((r) => r.visibility === 'unlisted'));
const grouped = $derived.by(() => {
const map = new Map<string, ExposedRecord[]>();
for (const rec of exposed) {
const list = map.get(rec.module) ?? [];
list.push(rec);
map.set(rec.module, list);
}
return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]));
});
async function setPrivate(rec: ExposedRecord) {
const key = `${rec.collection}/${rec.id}`;
busyKey = key;
try {
await setRecordVisibility(rec.collection, rec.id, 'space');
toastStore.show?.(`„${rec.title}" ist jetzt privat`);
} catch (e) {
console.error(e);
toastStore.show?.(`Konnte „${rec.title}" nicht zurückstufen`);
} finally {
busyKey = null;
}
}
async function killSwitch() {
killing = true;
try {
const { flipped, failed } = await resetAllExposedToSpace();
confirmKill = false;
if (failed > 0) {
toastStore.show?.(`${flipped} Einträge auf privat — ${failed} fehlgeschlagen`);
} else {
toastStore.show?.(`${flipped} Einträge auf privat zurückgesetzt`);
}
} catch (e) {
console.error(e);
toastStore.show?.('Kill-Switch fehlgeschlagen');
} finally {
killing = false;
}
}
</script>
<SettingsPanel id="privacy">
<SettingsSectionHeader
icon={ShieldCheck}
title="Privatsphäre-Übersicht"
description="Alle Einträge, die du gerade öffentlich zeigst oder per Link teilst — mit ein-Klick-Rückzieher."
tone="indigo"
/>
<div class="summary">
<div class="summary-card">
<Globe size={18} />
<div>
<span class="summary-count">{publicRecords.length}</span>
<span class="summary-label">öffentlich</span>
</div>
</div>
<div class="summary-card">
<LinkIcon size={18} />
<div>
<span class="summary-count">{unlistedRecords.length}</span>
<span class="summary-label">per Link teilbar</span>
</div>
</div>
</div>
{#if loading}
<p class="muted">Lädt…</p>
{:else if exposed.length === 0}
<p class="muted empty">Aktuell ist nichts öffentlich oder per Link geteilt — gut gemacht.</p>
{:else}
<div class="groups">
{#each grouped as [module, records] (module)}
<section class="group">
<header class="group-header">
<h3>{records[0]?.moduleLabel ?? module}</h3>
<span class="group-count">{records.length}</span>
</header>
<ul class="record-list">
{#each records as rec (`${rec.collection}/${rec.id}`)}
<li class="record">
<div class="record-meta">
<span class="record-title">{rec.title}</span>
<span class="record-badge" class:badge-unlisted={rec.visibility === 'unlisted'}>
{rec.visibility === 'public' ? 'Öffentlich' : 'Per Link'}
</span>
</div>
<div class="record-actions">
{#if rec.openHref}
<a class="link" href={rec.openHref}>Öffnen</a>
{/if}
<button
class="btn btn-ghost"
disabled={busyKey === `${rec.collection}/${rec.id}`}
onclick={() => setPrivate(rec)}
>
Privat
</button>
</div>
</li>
{/each}
</ul>
</section>
{/each}
</div>
<div class="kill-zone">
{#if !confirmKill}
<button class="btn btn-danger" onclick={() => (confirmKill = true)}>
Alle auf privat zurücksetzen
</button>
{:else}
<div class="confirm">
<p>
<strong>{exposed.length}</strong>
{exposed.length === 1 ? 'Eintrag' : 'Einträge'} werden auf "Space" zurückgesetzt. Aktive Share-Links
werden widerrufen. Fortfahren?
</p>
<div class="confirm-actions">
<button class="btn" onclick={() => (confirmKill = false)} disabled={killing}>
Abbrechen
</button>
<button class="btn btn-danger" onclick={killSwitch} disabled={killing}>
{killing ? 'Setze zurück…' : 'Ja, alles zurücksetzen'}
</button>
</div>
</div>
{/if}
</div>
{/if}
</SettingsPanel>
<style>
.summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
margin: 1rem 0 1.5rem;
}
.summary-card {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.85rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
}
.summary-card div {
display: flex;
flex-direction: column;
}
.summary-count {
font-size: 1.25rem;
font-weight: 600;
line-height: 1;
}
.summary-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.muted {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.empty {
padding: 1.25rem;
text-align: center;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.625rem;
}
.groups {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 0.5rem;
}
.group {
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
overflow: hidden;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.9rem;
background: hsl(var(--color-card));
border-bottom: 1px solid hsl(var(--color-border));
}
.group-header h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
text-transform: capitalize;
}
.group-count {
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
padding: 0.15rem 0.5rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: 999px;
}
.record-list {
margin: 0;
padding: 0;
list-style: none;
}
.record {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.55rem 0.9rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.record:last-child {
border-bottom: none;
}
.record-meta {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
flex: 1;
}
.record-title {
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
padding: 0.1rem 0.4rem;
border: 1px solid hsl(var(--color-border));
border-radius: 999px;
flex-shrink: 0;
}
.record-badge.badge-unlisted {
color: rgb(99, 102, 241);
border-color: rgba(99, 102, 241, 0.45);
}
.record-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.link {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
}
.link:hover {
color: inherit;
text-decoration: underline;
}
.btn {
padding: 0.3rem 0.65rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.4rem;
background: hsl(var(--color-card));
color: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.5);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
}
.btn-danger {
background: rgba(248, 113, 113, 0.1);
border-color: rgba(248, 113, 113, 0.5);
color: rgb(220, 38, 38);
}
.btn-danger:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.2);
}
.kill-zone {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid hsl(var(--color-border));
}
.confirm {
padding: 1rem;
background: rgba(248, 113, 113, 0.06);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.625rem;
}
.confirm p {
margin: 0 0 0.75rem;
font-size: 0.875rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,338 @@
/**
* Visibility overview helpers read-side aggregator for the
* /settings privacy section.
*
* Walks every visibility-aware Dexie table and returns a flat list of
* records currently flipped to 'public' or 'unlisted', so the user has
* a single dashboard to audit "what am I exposing right now". Pairs
* with `setRecordVisibility` for one-click downgrade and
* `resetAllPublicToSpace` for the kill-switch.
*
* Adding a new visibility-aware module: append to TABLES below.
* The reader is generic it just needs to know the collection name,
* the title-extraction strategy, and the fixer (which downgrade
* action to call).
*/
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getEffectiveUserId } from '$lib/data/current-user';
import type { VisibilityLevel } from '@mana/shared-privacy';
export interface ExposedRecord {
module: string;
moduleLabel: string;
collection: string;
id: string;
title: string;
visibility: 'public' | 'unlisted';
unlistedToken?: string;
/** Best-effort module route the user can open to manage the record. */
openHref?: string;
}
interface TableConfig {
module: string;
collection: string;
moduleLabel: string;
/** True when the table holds encrypted user content needing decrypt. */
encrypted: boolean;
/** Pull a human-readable label out of the (possibly decrypted) row. */
title: (row: Record<string, unknown>) => string;
/** Build a deep-link to the record. */
href?: (id: string) => string;
/**
* Invoke the module's setVisibility flow. We dynamic-import the
* store so the settings page doesn't pay the bundle cost upfront.
*/
setVisibility: (id: string, next: VisibilityLevel) => Promise<unknown>;
}
function asString(v: unknown, fallback = 'Ohne Titel'): string {
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
return fallback;
}
const TABLES: TableConfig[] = [
{
module: 'library',
collection: 'libraryEntries',
moduleLabel: 'Bibliothek',
encrypted: true,
title: (r) => asString(r.title),
href: (id) => `/library/entry/${id}`,
setVisibility: async (id, next) => {
const { libraryEntriesStore } = await import('$lib/modules/library/stores/entries.svelte');
return libraryEntriesStore.setVisibility(id, next);
},
},
{
module: 'picture',
collection: 'boards',
moduleLabel: 'Bilder (Boards)',
encrypted: false,
title: (r) => asString(r.name ?? r.title),
href: (id) => `/picture/board/${id}`,
setVisibility: async (id, next) => {
const { boardsStore } = await import('$lib/modules/picture/stores/boards.svelte');
return boardsStore.setVisibility(id, next);
},
},
{
module: 'calendar',
collection: 'events',
moduleLabel: 'Kalender',
encrypted: true,
title: (r) => asString(r.title),
href: (id) => `/calendar/event/${id}`,
setVisibility: async (id, next) => {
const { eventsStore } = await import('$lib/modules/calendar/stores/events.svelte');
return eventsStore.setVisibility(id, next);
},
},
{
module: 'todo',
collection: 'tasks',
moduleLabel: 'Aufgaben',
encrypted: true,
title: (r) => asString(r.title),
href: () => '/todo',
setVisibility: async (id, next) => {
const { tasksStore } = await import('$lib/modules/todo/stores/tasks.svelte');
return tasksStore.setVisibility(id, next);
},
},
{
module: 'goals',
collection: 'goals',
moduleLabel: 'Ziele',
encrypted: false,
title: (r) => asString(r.title),
href: () => '/goals',
setVisibility: async (id, next) => {
const { goalStore } = await import('$lib/companion/goals/store');
return goalStore.setVisibility(id, next);
},
},
{
module: 'places',
collection: 'places',
moduleLabel: 'Orte',
encrypted: true,
title: (r) => asString(r.name),
href: (id) => `/places/place/${id}`,
setVisibility: async (id, next) => {
const { placesStore } = await import('$lib/modules/places/stores/places.svelte');
return placesStore.setVisibility(id, next);
},
},
{
module: 'recipes',
collection: 'recipes',
moduleLabel: 'Rezepte',
encrypted: true,
title: (r) => asString(r.title),
href: () => '/recipes',
setVisibility: async (id, next) => {
const { recipesStore } = await import('$lib/modules/recipes/stores/recipes.svelte');
return recipesStore.setVisibility(id, next);
},
},
{
module: 'wardrobe',
collection: 'wardrobeOutfits',
moduleLabel: 'Wardrobe (Outfits)',
encrypted: true,
title: (r) => asString(r.name),
href: () => '/wardrobe',
setVisibility: async (id, next) => {
const { wardrobeOutfitsStore } = await import('$lib/modules/wardrobe/stores/outfits.svelte');
return wardrobeOutfitsStore.setVisibility(id, next);
},
},
{
module: 'comic',
collection: 'comicStories',
moduleLabel: 'Comics',
encrypted: true,
title: (r) => asString(r.title),
href: () => '/comic',
setVisibility: async (id, next) => {
const { comicStoriesStore } = await import('$lib/modules/comic/stores/stories.svelte');
return comicStoriesStore.setVisibility(id, next);
},
},
{
module: 'habits',
collection: 'habits',
moduleLabel: 'Habits',
encrypted: true,
title: (r) => asString(r.title),
href: () => '/habits',
setVisibility: async (id, next) => {
const { habitsStore } = await import('$lib/modules/habits/stores/habits.svelte');
return habitsStore.setVisibility(id, next);
},
},
{
module: 'quiz',
collection: 'quizzes',
moduleLabel: 'Quizze',
encrypted: true,
title: (r) => asString(r.title),
href: (id) => `/quiz/${id}/edit`,
setVisibility: async (id, next) => {
const { quizzesStore } = await import('$lib/modules/quiz/stores/quizzes.svelte');
return quizzesStore.setVisibility(id, next);
},
},
{
module: 'events',
collection: 'socialEvents',
moduleLabel: 'Events (RSVP)',
encrypted: true,
title: (r) => asString(r.title),
href: (id) => `/events/${id}`,
setVisibility: async (id, next) => {
const { eventsStore } = await import('$lib/modules/events/stores/events.svelte');
return eventsStore.setVisibility(id, next);
},
},
{
module: 'memoro',
collection: 'memos',
moduleLabel: 'Memoro',
encrypted: true,
title: (r) => asString(r.title ?? r.intro),
href: (id) => `/memoro/${id}`,
setVisibility: async (id, next) => {
const { memosStore } = await import('$lib/modules/memoro/stores/memos.svelte');
return memosStore.setVisibility(id, next);
},
},
{
module: 'cards',
collection: 'cardDecks',
moduleLabel: 'Karten (Decks)',
encrypted: true,
title: (r) => asString(r.name),
href: (id) => `/cards/deck/${id}`,
setVisibility: async (id, next) => {
const { deckStore } = await import('$lib/modules/cards/stores/decks.svelte');
return deckStore.setVisibility(id, next);
},
},
{
module: 'presi',
collection: 'presiDecks',
moduleLabel: 'Präsentationen',
encrypted: true,
title: (r) => asString(r.title),
href: (id) => `/presi/deck/${id}`,
setVisibility: async (id, next) => {
const { decksStore } = await import('$lib/modules/presi/stores/decks.svelte');
return decksStore.setVisibility(id, next);
},
},
];
/**
* Walk every visibility-aware table and return all records currently
* flipped to 'public' or 'unlisted'. Decrypts encrypted titles so the
* user can recognize what they're looking at.
*/
export async function listExposedRecords(): Promise<ExposedRecord[]> {
const out: ExposedRecord[] = [];
for (const cfg of TABLES) {
try {
const all = await db.table(cfg.collection).toArray();
const exposed = all.filter(
(r: Record<string, unknown>) =>
!r.deletedAt && (r.visibility === 'public' || r.visibility === 'unlisted')
);
if (exposed.length === 0) continue;
const rows = cfg.encrypted
? ((await decryptRecords(cfg.collection, exposed)) as Record<string, unknown>[])
: exposed;
for (const row of rows) {
const id = String(row.id ?? '');
if (!id) continue;
out.push({
module: cfg.module,
moduleLabel: cfg.moduleLabel,
collection: cfg.collection,
id,
title: cfg.title(row),
visibility: row.visibility as 'public' | 'unlisted',
unlistedToken: row.unlistedToken as string | undefined,
openHref: cfg.href?.(id),
});
}
} catch (e) {
// Don't let one broken module take down the whole overview.
console.warn(`[privacy] reading ${cfg.collection} failed`, e);
}
}
return out.sort((a, b) => a.module.localeCompare(b.module) || a.title.localeCompare(b.title));
}
/**
* Downgrade a single record to 'space' via its module's setVisibility
* flow preserves the proper revoke-server-snapshot behavior for
* unlisted records (calendar/library/places).
*/
export async function setRecordVisibility(
collection: string,
id: string,
next: VisibilityLevel
): Promise<void> {
const cfg = TABLES.find((t) => t.collection === collection);
if (!cfg) {
// Generic fallback — write directly so unknown collections still
// move out of public when the kill-switch fires.
const stamp = new Date().toISOString();
await db.table(collection).update(id, {
visibility: next,
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,
});
emitDomainEvent('VisibilityChanged', collection, collection, id, {
recordId: id,
collection,
before: 'unknown',
after: next,
});
return;
}
await cfg.setVisibility(id, next);
}
/**
* Kill-switch: flip every public/unlisted record back to 'space' in
* one shot. Errors on individual records are logged but don't abort
* the sweep the user expects "make me private" to be best-effort
* thorough, not a transaction.
*/
export async function resetAllExposedToSpace(): Promise<{ flipped: number; failed: number }> {
const exposed = await listExposedRecords();
let flipped = 0;
let failed = 0;
for (const rec of exposed) {
try {
await setRecordVisibility(rec.collection, rec.id, 'space');
flipped++;
} catch (e) {
failed++;
console.error(`[privacy] reset ${rec.collection}/${rec.id} failed`, e);
}
}
return { flipped, failed };
}
export const VISIBILITY_AWARE_MODULE_COUNT = TABLES.length;

View file

@ -15,6 +15,7 @@
import GeneralSection from '$lib/components/settings/sections/GeneralSection.svelte';
import AiSection from '$lib/components/settings/sections/AiSection.svelte';
import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte';
import PrivacySection from '$lib/components/settings/sections/PrivacySection.svelte';
import DataSection from '$lib/components/settings/sections/DataSection.svelte';
import TagPresetsSection from '$lib/components/settings/sections/TagPresetsSection.svelte';
@ -76,6 +77,8 @@
<AiSection />
{:else if activeCategory === 'security'}
<SecuritySection />
{:else if activeCategory === 'privacy'}
<PrivacySection />
{:else if activeCategory === 'data'}
<DataSection />
{:else if activeCategory === 'tag-presets'}