Phase 9e: Account-Page mit DSGVO-Self-Service

Neuer User-JWT-Pfad GET/POST /api/v1/me/{export,delete} — gespiegelte
DSGVO-Logik aus dem Service-Key-Pfad, aber gegen die eigene User-ID
gated. buildUserExport extrahiert in dsgvo.ts und wird von beiden
Routern geteilt.

/account-Page zeigt User-ID, Logout, JSON-Daten-Export (Download als
Blob), und einen rot-markierten Account-Delete-Knopf mit "LÖSCHEN"-
Confirmation. Logout im Header verlinkt jetzt auf /account statt
direkt clear() — der User sieht zuerst, was an seinem Account hängt.

Andere mana-Apps werden nicht mit gelöscht — der UI-Hinweistext zeigt
auf die spätere Verein-DSGVO-Sammelanfrage über mana-admin.

48 API-Tests grün (+2 neue auth-gate-Tests für /me), web type-check
374 files 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 18:03:41 +02:00
parent aff4d9536a
commit 03117d5869
7 changed files with 316 additions and 52 deletions

View file

@ -0,0 +1,30 @@
import { api } from './client.ts';
export interface UserExport {
user_id: string;
exported_at: string;
app: string;
app_version: string;
data: {
decks: unknown[];
cards: unknown[];
reviews: unknown[];
study_sessions: unknown[];
tags: unknown[];
media_refs: unknown[];
import_jobs: unknown[];
};
}
/** Lädt den vollständigen DSGVO-Export der eigenen Daten (User-JWT). */
export function exportMe() {
return api<UserExport>('/api/v1/me/export');
}
/** Löscht alle Cards-Daten des eingeloggten Users (User-JWT). */
export function deleteMe() {
return api<{ deleted: true; user_id: string; counts: { decks: number; import_jobs: number } }>(
'/api/v1/me/delete',
{ method: 'POST' }
);
}

View file

@ -36,15 +36,13 @@
<div class="flex items-center gap-3 text-sm">
{#if devUser.id}
<span class="text-[var(--color-muted)]"
>{devUser.id}
</span>
<button
class="rounded border px-2 py-1 text-xs hover:bg-[var(--color-border)] border-[var(--color-border)]"
onclick={() => devUser.clear()}
<a
href="/account"
class="truncate max-w-[180px] text-[var(--color-muted)] hover:text-[var(--color-fg)]"
title={devUser.id}
>
Logout (dev)
</button>
{devUser.id}
</a>
{:else}
<button
class="rounded bg-[var(--color-primary)] px-3 py-1 text-[var(--color-primary-fg)]"

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { exportMe, deleteMe } from '$lib/api/me.ts';
import { listDecks } from '$lib/api/decks.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let stats = $state<{ decks: number; cards: number } | null>(null);
let exporting = $state(false);
let deleting = $state(false);
onMount(async () => {
if (!devUser.id) {
goto('/');
return;
}
try {
const r = await listDecks();
const cards = r.decks.reduce((sum) => sum, 0); // listDecks returns total decks; cards via separate calls
stats = { decks: r.total, cards };
} catch {
stats = null;
}
});
async function onExport() {
exporting = true;
try {
const data = await exportMe();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cards-export-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toasts.success(
`Export geladen: ${data.data.decks.length} Decks, ${data.data.cards.length} Karten, ${data.data.reviews.length} Reviews.`
);
} catch (e) {
toasts.error(`Export fehlgeschlagen: ${(e as Error).message}`);
} finally {
exporting = false;
}
}
async function onDelete() {
const confirmation = prompt(
'ALLE deine Cards-Daten werden unwiderruflich gelöscht. Tippe LÖSCHEN zur Bestätigung.'
);
if (confirmation !== 'LÖSCHEN') return;
deleting = true;
try {
const r = await deleteMe();
toasts.success(`Gelöscht: ${r.counts.decks} Decks, ${r.counts.import_jobs} Import-Jobs.`);
devUser.clear();
goto('/');
} catch (e) {
toasts.error(`Löschen fehlgeschlagen: ${(e as Error).message}`);
deleting = false;
}
}
function logout() {
devUser.clear();
goto('/');
}
</script>
<svelte:head>
<title>Account · Cards</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="text-2xl font-semibold">Account</h1>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-sm">
<div class="text-[var(--color-muted)]">User-ID</div>
<code class="mt-1 block break-all text-sm">{devUser.id ?? '—'}</code>
</div>
<div class="mt-3 flex gap-3">
<button
type="button"
onclick={logout}
class="rounded border border-[var(--color-border)] px-3 py-1.5 text-sm hover:bg-[var(--color-border)]/40"
>
Abmelden
</button>
</div>
<p class="mt-3 text-xs text-[var(--color-muted)]">
Phase-2-Hinweis: aktuell ist die Identität ein Dev-Stub (sessionStorage). Mit Auth-Föderation
wechselt das auf einen mana-auth-Login gegen <code>auth.mana.how</code>.
</p>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<h2 class="text-lg font-medium">Daten-Export</h2>
<p class="mt-1 text-sm text-[var(--color-muted)]">
Lade alle deine Cards-Daten als JSON herunter — Decks, Karten, Reviews, Study-Sessions, Tags,
Media-Refs, Import-Jobs. DSGVO Art. 15/20.
</p>
<button
type="button"
onclick={onExport}
disabled={exporting}
class="mt-3 rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
>
{exporting ? 'Lade…' : 'Daten exportieren'}
</button>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-danger)]/30 bg-[var(--color-card)] p-4">
<h2 class="text-lg font-medium text-[var(--color-danger)]">Konto löschen</h2>
<p class="mt-1 text-sm text-[var(--color-muted)]">
Löscht unwiderruflich alle deine Cards-Daten. DSGVO Art. 17. Andere mana-Apps (Memoro, Who, …)
behalten ihre Daten unabhängig — wenn du dort auch löschen willst, mach das jeweils dort oder
nutze die Verein-DSGVO-Sammelanfrage über mana-admin.
</p>
<button
type="button"
onclick={onDelete}
disabled={deleting}
class="mt-3 rounded border border-[var(--color-danger)] px-4 py-1.5 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 disabled:opacity-50"
>
{deleting ? 'Lösche…' : 'Alle Cards-Daten löschen'}
</button>
</section>
</div>