Add GDPR data export and account deletion

This commit is contained in:
fullsizemalt 2025-12-26 22:06:26 -08:00
parent 80c686da53
commit 7535f670d8
2 changed files with 192 additions and 2 deletions

View file

@ -270,6 +270,136 @@ def get_user_groups(
).all() ).all()
return groups return groups
# --- GDPR Data Export ---
@router.get("/me/export")
def export_my_data(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Export all user data for GDPR compliance"""
from models import Rating, Comment, Notification, Badge
# Get profile
profile = session.exec(select(Profile).where(Profile.user_id == current_user.id)).first()
# Get preferences
prefs = current_user.preferences
# Get attendance
attendance = session.exec(
select(Attendance, Show)
.join(Show, Attendance.show_id == Show.id)
.where(Attendance.user_id == current_user.id)
).all()
# Get reviews
reviews = session.exec(
select(Review).where(Review.user_id == current_user.id)
).all()
# Get ratings
ratings = session.exec(
select(Rating).where(Rating.user_id == current_user.id)
).all()
# Get groups
groups = session.exec(
select(Group)
.join(GroupMember, Group.id == GroupMember.group_id)
.where(GroupMember.user_id == current_user.id)
).all()
return {
"account": {
"id": current_user.id,
"email": current_user.email,
"joined_at": current_user.joined_at.isoformat() if current_user.joined_at else None,
"xp": current_user.xp,
"level": current_user.level,
},
"profile": {
"username": profile.username if profile else None,
"display_name": profile.display_name if profile else None,
"bio": current_user.bio,
"avatar_bg_color": current_user.avatar_bg_color,
"avatar_text": current_user.avatar_text,
},
"preferences": {
"wiki_mode": prefs.wiki_mode if prefs else False,
"show_ratings": prefs.show_ratings if prefs else True,
"show_comments": prefs.show_comments if prefs else True,
"theme": prefs.theme if prefs else "system",
},
"privacy": {
"profile_public": current_user.profile_public,
"show_attendance_public": current_user.show_attendance_public,
"appear_in_leaderboards": current_user.appear_in_leaderboards,
},
"attendance": [
{"show_id": a.show_id, "date": s.date.isoformat() if s.date else None}
for a, s in attendance
],
"reviews": [
{"id": r.id, "score": r.score, "blurb": r.blurb, "content": r.content, "created_at": r.created_at.isoformat() if r.created_at else None}
for r in reviews
],
"ratings": [
{"id": r.id, "score": r.score, "performance_id": r.performance_id, "show_id": r.show_id}
for r in ratings
],
"groups": [
{"id": g.id, "name": g.name}
for g in groups
],
}
# --- Account Deletion ---
class DeleteAccountRequest(BaseModel):
confirm_email: str
@router.delete("/me")
def delete_my_account(
request: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Permanently delete user account and all associated data"""
from models import Rating, Comment, Notification, Badge
# Verify email confirmation matches
if request.confirm_email.lower() != current_user.email.lower():
raise HTTPException(status_code=400, detail="Email confirmation doesn't match")
user_id = current_user.id
# Delete related data in order (foreign key constraints)
session.exec(select(Review).where(Review.user_id == user_id)).all()
for review in session.exec(select(Review).where(Review.user_id == user_id)).all():
session.delete(review)
for rating in session.exec(select(Rating).where(Rating.user_id == user_id)).all():
session.delete(rating)
for attendance in session.exec(select(Attendance).where(Attendance.user_id == user_id)).all():
session.delete(attendance)
for membership in session.exec(select(GroupMember).where(GroupMember.user_id == user_id)).all():
session.delete(membership)
for profile in session.exec(select(Profile).where(Profile.user_id == user_id)).all():
session.delete(profile)
if current_user.preferences:
session.delete(current_user.preferences)
# Finally delete the user
session.delete(current_user)
session.commit()
return {"message": "Account deleted successfully"}
# --- Dynamic ID Routes (must be last to avoid conflicts with /me, /avatar) --- # --- Dynamic ID Routes (must be last to avoid conflicts with /me, /avatar) ---
@router.get("/{user_id}", response_model=UserRead) @router.get("/{user_id}", response_model=UserRead)

View file

@ -661,10 +661,70 @@ function PrivacySection({ settings, onChange }: {
<div className="pt-4"> <div className="pt-4">
<h4 className="text-sm font-medium mb-2">Danger Zone</h4> <h4 className="text-sm font-medium mb-2">Danger Zone</h4>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button variant="outline" size="sm" className="w-fit" disabled> <Button
variant="outline"
size="sm"
className="w-fit"
onClick={async () => {
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/users/me/export`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
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 = 'my-elmeg-data.json'
a.click()
URL.revokeObjectURL(url)
}
} catch (err) {
console.error('Export failed', err)
}
}}
>
Export My Data Export My Data
</Button> </Button>
<Button variant="destructive" size="sm" className="w-fit" disabled> <Button
variant="destructive"
size="sm"
className="w-fit"
onClick={async () => {
const email = prompt("To delete your account, please type your email address:")
if (!email) return
const confirm = window.confirm("Are you absolutely sure? This action cannot be undone. All your data will be permanently deleted.")
if (!confirm) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/users/me`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ confirm_email: email })
})
if (res.ok) {
alert("Account deleted. Goodbye!")
localStorage.removeItem("token")
window.location.href = "/"
} else {
const data = await res.json()
alert(data.detail || "Deletion failed")
}
} catch (err) {
console.error('Deletion failed', err)
alert("Error deleting account")
}
}}
>
Delete Account Delete Account
</Button> </Button>
</div> </div>