From 03117d5869fbac327fad3420ddb58e5c341b101a Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 18:03:41 +0200 Subject: [PATCH] Phase 9e: Account-Page mit DSGVO-Self-Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/index.ts | 2 + apps/api/src/routes/dsgvo.ts | 102 +++++++++-------- apps/api/src/routes/me.ts | 57 ++++++++++ apps/api/tests/me.test.ts | 31 +++++ apps/web/src/lib/api/me.ts | 30 +++++ apps/web/src/lib/components/Header.svelte | 14 +-- apps/web/src/routes/account/+page.svelte | 132 ++++++++++++++++++++++ 7 files changed, 316 insertions(+), 52 deletions(-) create mode 100644 apps/api/src/routes/me.ts create mode 100644 apps/api/tests/me.test.ts create mode 100644 apps/web/src/lib/api/me.ts create mode 100644 apps/web/src/routes/account/+page.svelte diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e83a88a..f084a05 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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({ diff --git a/apps/api/src/routes/dsgvo.ts b/apps/api/src/routes/dsgvo.ts index 69ca893..fbb7065 100644 --- a/apps/api/src/routes/dsgvo.ts +++ b/apps/api/src/routes/dsgvo.ts @@ -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)); }); /** diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts new file mode 100644 index 0000000..301139c --- /dev/null +++ b/apps/api/src/routes/me.ts @@ -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; +} diff --git a/apps/api/tests/me.test.ts b/apps/api/tests/me.test.ts new file mode 100644 index 0000000..89c2dfb --- /dev/null +++ b/apps/api/tests/me.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/api/me.ts b/apps/web/src/lib/api/me.ts new file mode 100644 index 0000000..feed378 --- /dev/null +++ b/apps/web/src/lib/api/me.ts @@ -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('/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' } + ); +} diff --git a/apps/web/src/lib/components/Header.svelte b/apps/web/src/lib/components/Header.svelte index 313bbc0..a324f41 100644 --- a/apps/web/src/lib/components/Header.svelte +++ b/apps/web/src/lib/components/Header.svelte @@ -36,15 +36,13 @@
{#if devUser.id} - {devUser.id} - - + {devUser.id} + {:else} +
+

+ Phase-2-Hinweis: aktuell ist die Identität ein Dev-Stub (sessionStorage). Mit Auth-Föderation + wechselt das auf einen mana-auth-Login gegen auth.mana.how. +

+ + +
+

Daten-Export

+

+ Lade alle deine Cards-Daten als JSON herunter — Decks, Karten, Reviews, Study-Sessions, Tags, + Media-Refs, Import-Jobs. DSGVO Art. 15/20. +

+ +
+ +
+

Konto löschen

+

+ 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. +

+ +
+