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:
parent
aff4d9536a
commit
03117d5869
7 changed files with 316 additions and 52 deletions
|
|
@ -10,6 +10,7 @@ import { shareRouter } from './routes/share.ts';
|
|||
import { toolsRouter } from './routes/tools.ts';
|
||||
import { searchRouter } from './routes/search.ts';
|
||||
import { dsgvoRouter } from './routes/dsgvo.ts';
|
||||
import { meRouter } from './routes/me.ts';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ app.route('/api/v1/share', shareRouter());
|
|||
app.route('/api/v1/tools', toolsRouter());
|
||||
app.route('/api/v1/search', searchRouter());
|
||||
app.route('/api/v1/dsgvo', dsgvoRouter());
|
||||
app.route('/api/v1/me', meRouter());
|
||||
|
||||
app.get('/', (c) =>
|
||||
c.json({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,63 @@ import { serviceKeyAuth } from '../middleware/service-key.ts';
|
|||
|
||||
export type DsgvoDeps = { db?: CardsDb };
|
||||
|
||||
/**
|
||||
* Sammelt alle User-Daten für einen DSGVO-Export. Gemeinsam genutzt
|
||||
* vom Service-Key-Endpoint (mana-admin-Fanout) und dem User-Self-
|
||||
* Export aus /api/v1/me.
|
||||
*/
|
||||
export async function buildUserExport(db: CardsDb, userId: string) {
|
||||
const [decksRows, cardsRows, reviewsRows, sessionsRows, tagsRows, mediaRows, importsRows] =
|
||||
await Promise.all([
|
||||
db.select().from(decks).where(eq(decks.userId, userId)),
|
||||
db.select().from(cards).where(eq(cards.userId, userId)),
|
||||
db.select().from(reviews).where(eq(reviews.userId, userId)),
|
||||
db.select().from(studySessions).where(eq(studySessions.userId, userId)),
|
||||
db.select().from(tags).where(eq(tags.userId, userId)),
|
||||
db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)),
|
||||
db.select().from(importJobs).where(eq(importJobs.userId, userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
exported_at: new Date().toISOString(),
|
||||
app: 'cards',
|
||||
app_version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
data: {
|
||||
decks: decksRows.map((d) => ({
|
||||
...d,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
updatedAt: d.updatedAt.toISOString(),
|
||||
})),
|
||||
cards: cardsRows.map((x) => ({
|
||||
...x,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
updatedAt: x.updatedAt.toISOString(),
|
||||
})),
|
||||
reviews: reviewsRows.map((r) => ({
|
||||
...r,
|
||||
due: r.due.toISOString(),
|
||||
lastReview: r.lastReview ? r.lastReview.toISOString() : null,
|
||||
})),
|
||||
study_sessions: sessionsRows.map((s) => ({
|
||||
...s,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null,
|
||||
})),
|
||||
tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })),
|
||||
media_refs: mediaRows.map((m) => ({
|
||||
...m,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
})),
|
||||
import_jobs: importsRows.map((j) => ({
|
||||
...j,
|
||||
createdAt: j.createdAt.toISOString(),
|
||||
finishedAt: j.finishedAt ? j.finishedAt.toISOString() : null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DSGVO-Endpunkte. Aufgerufen von mana-admin im Verein-DSGVO-
|
||||
* Fan-Out (Auskunft Art. 15/20 + Löschung Art. 17).
|
||||
|
|
@ -36,50 +93,7 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
r.get('/export', async (c) => {
|
||||
const userId = c.req.query('user_id');
|
||||
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
|
||||
|
||||
const db = dbOf();
|
||||
const [decksRows, cardsRows, reviewsRows, sessionsRows, tagsRows, mediaRows, importsRows] =
|
||||
await Promise.all([
|
||||
db.select().from(decks).where(eq(decks.userId, userId)),
|
||||
db.select().from(cards).where(eq(cards.userId, userId)),
|
||||
db.select().from(reviews).where(eq(reviews.userId, userId)),
|
||||
db.select().from(studySessions).where(eq(studySessions.userId, userId)),
|
||||
db.select().from(tags).where(eq(tags.userId, userId)),
|
||||
db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)),
|
||||
db.select().from(importJobs).where(eq(importJobs.userId, userId)),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
user_id: userId,
|
||||
exported_at: new Date().toISOString(),
|
||||
app: 'cards',
|
||||
app_version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
data: {
|
||||
decks: decksRows.map((d) => ({ ...d, createdAt: d.createdAt.toISOString(), updatedAt: d.updatedAt.toISOString() })),
|
||||
cards: cardsRows.map((x) => ({
|
||||
...x,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
updatedAt: x.updatedAt.toISOString(),
|
||||
})),
|
||||
reviews: reviewsRows.map((r) => ({
|
||||
...r,
|
||||
due: r.due.toISOString(),
|
||||
lastReview: r.lastReview ? r.lastReview.toISOString() : null,
|
||||
})),
|
||||
study_sessions: sessionsRows.map((s) => ({
|
||||
...s,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null,
|
||||
})),
|
||||
tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })),
|
||||
media_refs: mediaRows.map((m) => ({ ...m, createdAt: m.createdAt.toISOString() })),
|
||||
import_jobs: importsRows.map((j) => ({
|
||||
...j,
|
||||
createdAt: j.createdAt.toISOString(),
|
||||
finishedAt: j.finishedAt ? j.finishedAt.toISOString() : null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
return c.json(await buildUserExport(dbOf(), userId));
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
57
apps/api/src/routes/me.ts
Normal file
57
apps/api/src/routes/me.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { decks, importJobs } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { buildUserExport } from './dsgvo.ts';
|
||||
|
||||
export type MeDeps = { db?: CardsDb };
|
||||
|
||||
/**
|
||||
* User-Self-Service-Endpunkte. Auth: User-JWT/Dev-Stub. Identisch in
|
||||
* Wirkung zum Service-Key-DSGVO-Pfad, aber gegen die eigene User-ID
|
||||
* gated — der User braucht keinen mana-admin-Detour, um seine eigenen
|
||||
* Daten zu sehen oder zu löschen.
|
||||
*/
|
||||
export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
|
||||
/** Voll-Export der eigenen Daten als JSON. */
|
||||
r.get('/export', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
return c.json(await buildUserExport(dbOf(), userId));
|
||||
});
|
||||
|
||||
/**
|
||||
* Vollständige Löschung der eigenen Cards-Daten. Identisch zur
|
||||
* Service-Key-Variante in /dsgvo/delete, aber für den eingeloggten
|
||||
* User selbst — ohne den Umweg über mana-admin.
|
||||
*/
|
||||
r.post('/delete', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const db = dbOf();
|
||||
const [deletedDecks, deletedImports] = await db.transaction(async (tx) => {
|
||||
const dd = await tx.delete(decks).where(eq(decks.userId, userId)).returning({ id: decks.id });
|
||||
const di = await tx
|
||||
.delete(importJobs)
|
||||
.where(eq(importJobs.userId, userId))
|
||||
.returning({ id: importJobs.id });
|
||||
return [dd, di];
|
||||
});
|
||||
|
||||
return c.json({
|
||||
deleted: true,
|
||||
user_id: userId,
|
||||
counts: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
31
apps/api/tests/me.test.ts
Normal file
31
apps/api/tests/me.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { meRouter } from '../src/routes/me.ts';
|
||||
import type { CardsDb } from '../src/db/connection.ts';
|
||||
|
||||
/**
|
||||
* Auth-Gate-Test ohne echte DB. Der Stub-DB kann keine Drizzle-
|
||||
* Queries beantworten — wir prüfen nur, dass die Route den
|
||||
* authMiddleware-Pfad ehrt.
|
||||
*/
|
||||
function buildApp() {
|
||||
const app = new Hono();
|
||||
const stub = {} as CardsDb;
|
||||
app.route('/api/v1/me', meRouter({ db: stub }));
|
||||
return { app };
|
||||
}
|
||||
|
||||
describe('meRouter — auth-gate', () => {
|
||||
it('GET /export ohne X-User-Id ist 401', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/me/export');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /delete ohne X-User-Id ist 401', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/me/delete', { method: 'POST' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
30
apps/web/src/lib/api/me.ts
Normal file
30
apps/web/src/lib/api/me.ts
Normal 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' }
|
||||
);
|
||||
}
|
||||
|
|
@ -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)]"
|
||||
|
|
|
|||
132
apps/web/src/routes/account/+page.svelte
Normal file
132
apps/web/src/routes/account/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue