Add GDPR data export and account deletion
This commit is contained in:
parent
80c686da53
commit
7535f670d8
2 changed files with 192 additions and 2 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue