diff --git a/apps/mana/apps/web/src/lib/components/settings/searchIndex.ts b/apps/mana/apps/web/src/lib/components/settings/searchIndex.ts index 83f803b7b..1edcbab9c 100644 --- a/apps/mana/apps/web/src/lib/components/settings/searchIndex.ts +++ b/apps/mana/apps/web/src/lib/components/settings/searchIndex.ts @@ -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', diff --git a/apps/mana/apps/web/src/lib/components/settings/sections/PrivacySection.svelte b/apps/mana/apps/web/src/lib/components/settings/sections/PrivacySection.svelte new file mode 100644 index 000000000..5de8310b8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/settings/sections/PrivacySection.svelte @@ -0,0 +1,369 @@ + + + + + + +
+
+ +
+ {publicRecords.length} + öffentlich +
+
+
+ +
+ {unlistedRecords.length} + per Link teilbar +
+
+
+ + {#if loading} +

Lädt…

+ {:else if exposed.length === 0} +

Aktuell ist nichts öffentlich oder per Link geteilt — gut gemacht.

+ {:else} +
+ {#each grouped as [module, records] (module)} +
+
+

{records[0]?.moduleLabel ?? module}

+ {records.length} +
+
    + {#each records as rec (`${rec.collection}/${rec.id}`)} +
  • +
    + {rec.title} + + {rec.visibility === 'public' ? 'Öffentlich' : 'Per Link'} + +
    +
    + {#if rec.openHref} + Öffnen + {/if} + +
    +
  • + {/each} +
+
+ {/each} +
+ +
+ {#if !confirmKill} + + {:else} +
+

+ {exposed.length} + {exposed.length === 1 ? 'Eintrag' : 'Einträge'} werden auf "Space" zurückgesetzt. Aktive Share-Links + werden widerrufen. Fortfahren? +

+
+ + +
+
+ {/if} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts b/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts new file mode 100644 index 000000000..39a4db592 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts @@ -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; + /** 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; +} + +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 { + const out: ExposedRecord[] = []; + + for (const cfg of TABLES) { + try { + const all = await db.table(cfg.collection).toArray(); + const exposed = all.filter( + (r: Record) => + !r.deletedAt && (r.visibility === 'public' || r.visibility === 'unlisted') + ); + if (exposed.length === 0) continue; + + const rows = cfg.encrypted + ? ((await decryptRecords(cfg.collection, exposed)) as Record[]) + : 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 { + 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; diff --git a/apps/mana/apps/web/src/lib/modules/settings/ListView.svelte b/apps/mana/apps/web/src/lib/modules/settings/ListView.svelte index a14f2c6f3..aa1dd08c7 100644 --- a/apps/mana/apps/web/src/lib/modules/settings/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/settings/ListView.svelte @@ -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 @@ {:else if activeCategory === 'security'} + {:else if activeCategory === 'privacy'} + {:else if activeCategory === 'data'} {:else if activeCategory === 'tag-presets'}