Implement cascading logbook deletion on account deletion

This commit is contained in:
2026-05-28 21:54:30 +02:00
parent ecdf8c2dc0
commit 71ea02416f
6 changed files with 156 additions and 8 deletions
+52 -1
View File
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, AlertTriangle } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
import { deleteAccount } from '../services/auth.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -46,6 +47,31 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const [shareCopied, setShareCopied] = useState(false)
const [loadingShareLink, setLoadingShareLink] = useState(false)
const handleDeleteAccount = async () => {
const confirmed = await showConfirm(
t('settings.delete_account_confirm_desc'),
t('settings.delete_account_confirm_title'),
t('settings.delete_account_confirm_yes'),
t('settings.delete_account_confirm_no')
)
if (confirmed) {
setSaving(true)
try {
const success = await deleteAccount()
if (success) {
window.location.reload()
} else {
showAlert(t('settings.delete_account_failed'))
}
} catch (err: any) {
showAlert(err.message || t('settings.delete_account_failed'))
} finally {
setSaving(false)
}
}
}
useEffect(() => {
if (logbookId) {
loadCollaborators()
@@ -484,6 +510,31 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
)}
</div>
)}
{/* Danger Zone / Account Deletion */}
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(239,68,68,0.2)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<AlertTriangle size={20} style={{ color: '#ef4444' }} />
<h3 style={{ margin: 0, color: '#ef4444', fontSize: '16px' }}>
{t('settings.danger_zone_title')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.danger_zone_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
<button
type="button"
className="btn danger"
onClick={handleDeleteAccount}
style={{ width: 'auto' }}
>
<Trash2 size={16} />
{t('settings.delete_account_btn')}
</button>
</div>
</div>
</div>
)
}
+9 -1
View File
@@ -214,7 +214,15 @@
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren"
"share_copy_btn": "Link kopieren",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen Ihres Kontos werden alle Ihre Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
"delete_account_confirm_title": "Konto löschen?",
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
}
}
}
+9 -1
View File
@@ -214,7 +214,15 @@
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link"
"share_copy_btn": "Copy Link",
"danger_zone_title": "Danger Zone",
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
"delete_account_btn": "Permanently Delete Account",
"delete_account_confirm_title": "Delete Account?",
"delete_account_confirm_desc": "Are you absolutely sure you want to permanently delete your account and all associated logbooks and E2E-encrypted data?",
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again."
}
}
}
+37
View File
@@ -10,6 +10,7 @@ import {
bufferToBase64
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
import { db } from './db.js'
const API_BASE = '/api/auth'
@@ -270,3 +271,39 @@ export function logoutUser() {
localStorage.removeItem('active_username')
localStorage.removeItem('active_userid')
}
export async function deleteAccount(): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
try {
const res = await fetch(`${API_BASE}/delete-account`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
if (res.ok) {
// Clear IndexedDB completely to prevent leaking residual encrypted E2E data on client
await Promise.all([
db.logbooks.clear(),
db.yachts.clear(),
db.crews.clear(),
db.deviations.clear(),
db.entries.clear(),
db.photos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear()
])
// Wipe localStorage and session variables
logoutUser()
return true
}
} catch (err) {
console.error('Failed to delete account:', err)
}
return false
}
+22 -5
View File
@@ -77,6 +77,14 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
}
}
}
// Clear local cache for any logbooks that are no longer on the server
const serverIds = new Set(serverLogbooks.map((lb: any) => lb.id))
const localLogbooksArray = await db.logbooks.toArray()
for (const lb of localLogbooksArray) {
if (lb.isSynced === 1 && !serverIds.has(lb.id)) {
await deleteLocalLogbookCache(lb.id)
}
}
// Update Dexie database cache
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
@@ -219,6 +227,19 @@ function localIdForCreate(): string {
return tempUUID
}
// Perform cascading deletion on all local Dexie tables for a specific logbook ID
export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.logbooks.delete(id)
await db.yachts.where({ logbookId: id }).delete()
await db.crews.where({ logbookId: id }).delete()
await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete()
await db.photos.where({ logbookId: id }).delete()
await db.gpsTracks.where({ logbookId: id }).delete()
await db.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete()
}
// Delete a logbook and all associated payloads locally and on server
export async function deleteLogbook(id: string): Promise<void> {
const userId = localStorage.getItem('active_userid')
@@ -260,9 +281,5 @@ export async function deleteLogbook(id: string): Promise<void> {
}
// Perform local cascading cleanup
await db.logbooks.delete(id)
await db.yachts.where({ logbookId: id }).delete()
await db.crews.where({ logbookId: id }).delete()
await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete()
await deleteLocalLogbookCache(id)
}
+27
View File
@@ -226,4 +226,31 @@ router.post('/login-verify', async (req, res) => {
}
})
// 5. Delete own account
router.delete('/delete-account', async (req: any, res) => {
try {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
const user = await prisma.user.findUnique({
where: { id: userId }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
await prisma.user.delete({
where: { id: userId }
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting account:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router