mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
c73f93ff12
commit
e0ec7fe33f
4 changed files with 732 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
338
apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts
Normal file
338
apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts
Normal 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;
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue