Devices/Magic: - Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic - Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar) - Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung - Realtime auf user_devices → Settings aktualisiert Magic-Bindings live - Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie): - magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill) - Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook), per Resend-Mail + in-Response - Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert) Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
import { serverSupabaseServiceRole } from "../../utils/useSupabase";
|
|
import { deleteUserUrgeLogs } from "../../db/urge";
|
|
import { deleteUserSosSessions } from "../../db/sosSession";
|
|
import { deleteUserStreaks } from "../../db/streak";
|
|
import { deleteUserPosts } from "../../db/community";
|
|
import { deleteAllUserCustomDomains } from "../../db/domains";
|
|
import {
|
|
deleteUserTrustedContacts,
|
|
deleteUserCoachSessions,
|
|
} from "../../db/user";
|
|
import { deleteProfile } from "../../db/profile";
|
|
import {
|
|
deleteAllMailConnections,
|
|
deleteUserMailClassificationSamples,
|
|
} from "../../db/mail";
|
|
import { writeConsentRevoke } from "../../db/consent";
|
|
import { usePrisma } from "../../utils/prisma";
|
|
import { listMagicRemovalCredentials } from "../../db/devices";
|
|
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
const supabase = serverSupabaseServiceRole(event);
|
|
const userId = user.id;
|
|
|
|
// DSGVO Art. 9: Consent-Widerruf für alle MailConnections vor Löschung
|
|
// (append-only — consent_logs-Rows bleiben für Beweiszwecke erhalten)
|
|
const db = usePrisma();
|
|
const mailConnections = await db.mailConnection.findMany({
|
|
where: { userId },
|
|
select: { id: true, consentVersion: true, authMethod: true },
|
|
});
|
|
|
|
const now = new Date();
|
|
for (const conn of mailConnections) {
|
|
// Widerruf-Log schreiben — fire-and-forget, kein throw wenn es fehlschlägt
|
|
writeConsentRevoke({
|
|
userId,
|
|
consentType: "art9-mail",
|
|
consentVersion: conn.consentVersion ?? "none",
|
|
revokedAt: now,
|
|
revokeReason: "account_deleted",
|
|
mailConnectionId: conn.id,
|
|
}).catch(() => {});
|
|
|
|
// TODO (mo — Mail-Stack): OAuth Token-Revoke bei MS bevor Row gelöscht wird.
|
|
// Wenn conn.authMethod === 'oauth2_microsoft': Token-Revoke best-effort.
|
|
// Tracking: consent-gap-plan.md TODO #2
|
|
}
|
|
|
|
// Magic Hard-Lock Reveal: BEVOR die Geräte gelöscht werden, dem User die
|
|
// Removal-Passwörter geben (Mail + Response), damit er die gesperrten
|
|
// Mac/Windows-Profile entfernen kann. Im Normalbetrieb bleibt das PW geheim.
|
|
let magicRemovalCredentials: Awaited<
|
|
ReturnType<typeof listMagicRemovalCredentials>
|
|
> = [];
|
|
try {
|
|
magicRemovalCredentials = await listMagicRemovalCredentials(userId);
|
|
if (magicRemovalCredentials.length > 0) {
|
|
const config = useRuntimeConfig(event);
|
|
const resendApiKey = (config as any).resendApiKey as string | undefined;
|
|
const { data: authData } = await supabase.auth.admin.getUserById(userId);
|
|
const email = authData?.user?.email;
|
|
if (email && resendApiKey) {
|
|
const p = await db.profile.findUnique({
|
|
where: { id: userId },
|
|
select: { nickname: true },
|
|
});
|
|
await sendMagicRemovalEmail({
|
|
recipientEmail: email,
|
|
recipientNickname: p?.nickname ?? null,
|
|
credentials: magicRemovalCredentials,
|
|
reason: "account_deletion",
|
|
resendApiKey,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[user-delete] magic removal reveal failed:", err);
|
|
}
|
|
|
|
// Delete all user data (DSGVO Art. 17)
|
|
// Reihenfolge: Samples VOR Connections löschen (oder parallel — FK-Reihenfolge
|
|
// egal weil wir nach userId filtern). Samples haben keine userId-FK-Cascade
|
|
// im Schema (connectionId ist nullable), daher manuelles Cleanup zwingend.
|
|
await Promise.all([
|
|
deleteUserUrgeLogs(userId),
|
|
deleteUserSosSessions(userId),
|
|
deleteUserStreaks(userId),
|
|
deleteUserPosts(userId),
|
|
deleteAllUserCustomDomains(userId),
|
|
deleteUserTrustedContacts(userId),
|
|
deleteUserCoachSessions(userId),
|
|
deleteUserMailClassificationSamples(userId),
|
|
deleteAllMailConnections(userId),
|
|
]);
|
|
|
|
// Profil zuletzt löschen (FK-Abhängigkeiten sind bereits entfernt)
|
|
await deleteProfile(userId).catch(() => {});
|
|
|
|
// Auth-User löschen (bleibt Supabase)
|
|
await supabase.auth.admin.deleteUser(userId);
|
|
|
|
// Removal-Passwörter im Response mitgeben (In-App-Reveal vor Logout).
|
|
return { success: true, magicRemovalCredentials };
|
|
});
|