Round 19: DSGVO/GDPR compliance, cookie consent, data export, account deletion, Jugendschutz
- Add Cookie Consent component (TTDSG-compliant, essential + analytics cookies) - Add GDPR Data Export API (DSGVO Art. 20 - data portability) - Add Account Deletion API (DSGVO Art. 17 - right to erasure, 30-day grace period) - Add Datenschutz (Privacy) tab in Settings with: - Privacy policy (data collected, purpose, retention, third-party sharing) - User rights (6 DSGVO rights with descriptions) - Data export button (download all personal data as JSON) - Account deletion with confirmation dialog and grace period - Jugendschutz (youth protection) notices - DPA/AVV section for admin (Auftragsverarbeitungsvertrag) - Add Jugendschutz notice in student login (parental consent for under 16) - Add 65+ i18n keys for DE and EN (DSGVO, privacy, Jugendschutz, DPA) - All German law terms accurate (DSGVO, Jugendschutzgesetz, TTDSG, AVV)
This commit is contained in:
1 parent
849b48a5a4
commit
6306217353
8 files changed
+1430
-2
No files matched your search
@@ -0,0 +1,208 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession, verifyPassword } from '@/lib/auth';
|
||||
|
||||
const requestDeletionSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const GRACE_PERIOD_DAYS = 30;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = requestDeletionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.userId;
|
||||
|
||||
// Fetch the user with password hash
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, passwordHash: true, deletedAt: true, role: true, email: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if already scheduled for deletion
|
||||
if (user.deletedAt) {
|
||||
const deletionDate = new Date(user.deletedAt);
|
||||
deletionDate.setDate(deletionDate.getDate() + GRACE_PERIOD_DAYS);
|
||||
return NextResponse.json({
|
||||
error: 'Account already scheduled for deletion',
|
||||
scheduledDeletionDate: deletionDate.toISOString(),
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordValid = await verifyPassword(parsed.data.password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
return NextResponse.json({ error: 'Wrong password' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Soft delete: set deletedAt to now
|
||||
const now = new Date();
|
||||
const scheduledDeletionDate = new Date(now);
|
||||
scheduledDeletionDate.setDate(scheduledDeletionDate.getDate() + GRACE_PERIOD_DAYS);
|
||||
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { deletedAt: now },
|
||||
});
|
||||
|
||||
// Create audit log entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'ACCOUNT_DELETION_REQUESTED',
|
||||
entity: 'User',
|
||||
entityId: userId,
|
||||
details: JSON.stringify({
|
||||
scheduledDeletionDate: scheduledDeletionDate.toISOString(),
|
||||
gracePeriodDays: GRACE_PERIOD_DAYS,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scheduledDeletionDate: scheduledDeletionDate.toISOString(),
|
||||
gracePeriodDays: GRACE_PERIOD_DAYS,
|
||||
message: `Account scheduled for deletion in ${GRACE_PERIOD_DAYS} days.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Account deletion error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel deletion (within grace period)
|
||||
export async function PUT() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.userId;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.deletedAt) {
|
||||
return NextResponse.json({
|
||||
error: 'Account is not scheduled for deletion',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if within grace period
|
||||
const deletionRequestedAt = new Date(user.deletedAt);
|
||||
const gracePeriodEnd = new Date(deletionRequestedAt);
|
||||
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + GRACE_PERIOD_DAYS);
|
||||
|
||||
if (new Date() > gracePeriodEnd) {
|
||||
return NextResponse.json({
|
||||
error: 'Grace period has expired. Account cannot be recovered.',
|
||||
}, { status: 410 });
|
||||
}
|
||||
|
||||
// Cancel deletion: clear deletedAt
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
|
||||
// Create audit log entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'ACCOUNT_DELETION_CANCELLED',
|
||||
entity: 'User',
|
||||
entityId: userId,
|
||||
details: JSON.stringify({
|
||||
cancelledAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Account deletion has been cancelled.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Account deletion cancellation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check deletion status
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.userId;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.deletedAt) {
|
||||
return NextResponse.json({
|
||||
scheduledForDeletion: false,
|
||||
});
|
||||
}
|
||||
|
||||
const deletionRequestedAt = new Date(user.deletedAt);
|
||||
const scheduledDeletionDate = new Date(deletionRequestedAt);
|
||||
scheduledDeletionDate.setDate(scheduledDeletionDate.getDate() + GRACE_PERIOD_DAYS);
|
||||
|
||||
return NextResponse.json({
|
||||
scheduledForDeletion: true,
|
||||
deletionRequestedAt: deletionRequestedAt.toISOString(),
|
||||
scheduledDeletionDate: scheduledDeletionDate.toISOString(),
|
||||
gracePeriodDays: GRACE_PERIOD_DAYS,
|
||||
canCancel: new Date() < scheduledDeletionDate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Account deletion status error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.userId;
|
||||
|
||||
// Fetch user profile
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
locale: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
schoolId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch school info
|
||||
const school = user.schoolId
|
||||
? await db.school.findUnique({
|
||||
where: { id: user.schoolId },
|
||||
select: { id: true, name: true, schoolType: true, address: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
// Fetch class groups the user is associated with
|
||||
const classGroupTeachers = await db.classGroupTeacher.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true, schoolType: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch learning progress entries created by the user
|
||||
const learningProgressEntries = await db.learningProgressEntry.findMany({
|
||||
where: { teacherId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
competencyId: true,
|
||||
level: true,
|
||||
comment: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 500,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch assessments created by the user
|
||||
const assessments = await db.assessment.findMany({
|
||||
where: { teacherId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch generated reports
|
||||
const reports = await db.report.findMany({
|
||||
where: { generatedByUserId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch audit log entries
|
||||
const auditLogs = await db.auditLog.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
entity: true,
|
||||
entityId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch data export requests
|
||||
const dataExportRequests = await db.dataExportRequest.findMany({
|
||||
where: { requestedByUserId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
scope: true,
|
||||
status: true,
|
||||
requestedAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
take: 100,
|
||||
orderBy: { requestedAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch teacher notes
|
||||
const teacherNotes = await db.teacherNote.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
category: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch attendance sessions
|
||||
const attendanceSessions = await db.attendanceSession.findMany({
|
||||
where: { teacherId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch notifications
|
||||
const notifications = await db.notification.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
title: true,
|
||||
message: true,
|
||||
read: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch notebooks
|
||||
const notebooks = await db.notebook.findMany({
|
||||
where: { ownerId: userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 100,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Fetch homework
|
||||
const homeworks = await db.homework.findMany({
|
||||
where: { createdById: userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
dueDate: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 100,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// For students: fetch their own data
|
||||
let studentData = null;
|
||||
if (user.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { email: user.email, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
enrollments: {
|
||||
select: {
|
||||
id: true,
|
||||
classGroup: { select: { name: true, gradeLevel: true } },
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
learningProgressEntries: {
|
||||
select: {
|
||||
id: true,
|
||||
level: true,
|
||||
comment: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
assessmentResults: {
|
||||
select: {
|
||||
id: true,
|
||||
score: true,
|
||||
comment: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
studentData = student;
|
||||
}
|
||||
|
||||
// For parents: fetch linked students
|
||||
let parentLinks = null;
|
||||
if (user.role === 'PARENT') {
|
||||
parentLinks = await db.parentStudentLink.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
relationship: true,
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Construct the export data
|
||||
const exportData: Record<string, unknown> = {
|
||||
exportMetadata: {
|
||||
exportDate: new Date().toISOString(),
|
||||
platform: 'CompetenceTrack',
|
||||
legalBasis: 'DSGVO Art. 20 - Recht auf Datenübertragbarkeit',
|
||||
userId: user.id,
|
||||
},
|
||||
userProfile: user,
|
||||
school,
|
||||
classAssociations: classGroupTeachers.map((cgt) => ({
|
||||
role: cgt.role,
|
||||
classGroup: cgt.classGroup,
|
||||
})),
|
||||
learningProgress: learningProgressEntries,
|
||||
assessments,
|
||||
reports,
|
||||
auditLogs,
|
||||
dataExportRequests,
|
||||
teacherNotes,
|
||||
attendanceSessions,
|
||||
notifications,
|
||||
notebooks,
|
||||
homeworks,
|
||||
};
|
||||
|
||||
if (studentData) {
|
||||
exportData.studentData = studentData;
|
||||
}
|
||||
|
||||
if (parentLinks) {
|
||||
exportData.parentLinks = parentLinks;
|
||||
}
|
||||
|
||||
return NextResponse.json(exportData, {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="competencetrack-data-export-${new Date().toISOString().split('T')[0]}.json"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('GDPR data export error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -5,6 +5,7 @@ import { useAppStore } from '@/lib/store';
|
||||
import { fetchCurrentUser } from '@/lib/api';
|
||||
import AuthView from '@/components/auth-view';
|
||||
import AppLayout from '@/components/app-layout';
|
||||
import CookieConsent from '@/components/cookie-consent';
|
||||
|
||||
export default function Home() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
@@ -48,8 +49,18 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <AuthView />;
|
||||
return (
|
||||
<>
|
||||
<AuthView />
|
||||
<CookieConsent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <AppLayout />;
|
||||
return (
|
||||
<>
|
||||
<AppLayout />
|
||||
<CookieConsent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user