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

@ -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({

View file

@ -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
View 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;
}