From 0e9fd1b3adfb0b24d98d50b54ac3df9f1b0d2e61 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 07:13:29 +0100 Subject: [PATCH] feat(account): danger zone, restore import, larger API key field on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Danger zone in settings: delete all own data or the account, both with password confirmation; the last server admin cannot delete the account - DELETE /user/data and /user/account; PurgeUserData removes everything a user owns in one transaction (others' tasks in shared lists stay theirs), also used by admin user deletion - MindSift import: 'Hinzufügen' or 'Wiederherstellen' (mode=restore, wipes own data only after the file is fully validated, confirm checkbox) - API key name field and button 44 px on phones (flex-1 collapsed them) Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 3 + Doku/modules/workspace.md | 2 +- frontend/src/components/FormSheet.tsx | 20 ++- .../src/features/settings/EcosystemView.tsx | 4 +- frontend/src/features/settings/ImportCard.tsx | 64 +++++++++- .../features/settings/UserSettingsView.tsx | 118 +++++++++++++++++- frontend/src/lib/api.ts | 26 +++- internal/api/server.go | 62 +++++++++ internal/storage/account.go | 92 ++++++++++++++ internal/storage/account_test.go | 92 ++++++++++++++ internal/storage/sqlite.go | 4 + 11 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 internal/storage/account.go create mode 100644 internal/storage/account_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 240b4a4..992321f 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -35,6 +35,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | DELETE | `/themes/{id}` | Eigenes Theme löschen | | POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) | +| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) | +| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) | +| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | ### Globale Suche (`GET /api/v1/search?q=`) diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index fd9a34d..cefea81 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -72,7 +72,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mi | Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. | - **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an. -- **Nie überschreiben:** Ein Import legt nur neu an; doppelte Titel sind möglich, Bestehendes bleibt unverändert. +- **Hinzufügen oder Wiederherstellen** (nur MindSift-Daten): „Hinzufügen“ legt nur neu an, Bestehendes bleibt unverändert. „Wiederherstellen“ (`?mode=restore`, in der Oberfläche nur nach bestätigtem Warnhinweis) löscht vorher alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen – erst nachdem die Datei vollständig gelesen und geprüft ist, eine kaputte Datei löscht also nichts. Mit dem Nutzer geteilte Inhalte anderer bleiben. Ein Obsidian-Import fügt immer hinzu. - **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken. - **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner. - Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte. diff --git a/frontend/src/components/FormSheet.tsx b/frontend/src/components/FormSheet.tsx index 1468cb9..b606b24 100644 --- a/frontend/src/components/FormSheet.tsx +++ b/frontend/src/components/FormSheet.tsx @@ -9,13 +9,25 @@ interface FormSheetProps { onSubmit: (e: React.FormEvent) => void; children: React.ReactNode; // the fields submitLabel?: string; + danger?: boolean; // red submit button for destructive actions + busy?: boolean; // Desktop only: open next to the click instead of centred anchor?: { x: number; y: number } | null; } // Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large // fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it. -export const FormSheet: React.FC = ({ title, icon, onClose, onSubmit, children, submitLabel = 'Erstellen', anchor }) => { +export const FormSheet: React.FC = ({ + title, + icon, + onClose, + onSubmit, + children, + submitLabel = 'Erstellen', + anchor, + danger = false, + busy = false, +}) => { const { inset, visibleHeight } = useKeyboardInset(); useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); @@ -61,7 +73,11 @@ export const FormSheet: React.FC = ({ title, icon, onClose, onSu - diff --git a/frontend/src/features/settings/EcosystemView.tsx b/frontend/src/features/settings/EcosystemView.tsx index cff2903..bafa526 100644 --- a/frontend/src/features/settings/EcosystemView.tsx +++ b/frontend/src/features/settings/EcosystemView.tsx @@ -115,11 +115,11 @@ export const EcosystemView: React.FC = ({ onIngested }) => { value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" - className="flex-1 h-9 px-3 input-field text-sm" + className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" /> + ))} + + )} +
{ e.preventDefault(); @@ -147,10 +185,30 @@ export const ImportCard: React.FC = ({ source, title, descripti )} + {!result && restoring && ( + + )} {!result && (
-
+ + {/* Danger zone: delete own data or the whole account (password required) */} +
+

+ + Gefahrenzone +

+
+
+

Alle eigenen Daten löschen

+

+ Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben. + Mit dir geteilte Inhalte anderer bleiben erhalten. +

+
+ +
+
+
+

Konto unwiderruflich löschen

+

+ {isLastAdmin + ? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.' + : 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'} +

+
+ +
+ {dangerDone &&

{dangerDone}

} +
)} @@ -1143,11 +1226,11 @@ export const UserSettingsView: React.FC = ({ value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…" - className="flex-1 h-9 px-3 input-field" + className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs" /> {showAbout && setShowAbout(false)} />} + {dangerAction && ( + } + onClose={() => setDangerAction(null)} + onSubmit={handleDangerSubmit} + submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'} + danger + busy={dangerBusy || !dangerPassword} + > +

+ {dangerAction === 'account' + ? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.' + : 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'} +

+ + {dangerError &&

{dangerError}

} +
+ )} ); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e7b6234..285a910 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -209,8 +209,8 @@ export const api = { return res; }, - async getMe(): Promise<{ user: User; stats: DashboardStats }> { - return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me'); + async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> { + return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me'); }, async updateSettings(patch: { @@ -1087,11 +1087,20 @@ export const api = { // Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP). // preview = true only reports what would be created. - async importData(source: 'mindsift' | 'obsidian', file: File, preview: boolean): Promise { + // mode 'restore' (MindSift data only) deletes the user's notes, tasks and lists before importing + async importData( + source: 'mindsift' | 'obsidian', + file: File, + preview: boolean, + mode: 'merge' | 'restore' = 'merge' + ): Promise { const token = getToken(); const form = new FormData(); form.append('file', file); - const res = await fetch(`/api/v1/import/${source}${preview ? '?preview=1' : ''}`, { + const params = new URLSearchParams(); + if (preview) params.set('preview', '1'); + if (mode === 'restore') params.set('mode', 'restore'); + const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form, @@ -1101,6 +1110,15 @@ export const api = { return data as ImportSummary; }, + // Danger zone: both need the current password + async purgeOwnData(password: string): Promise { + await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) }); + }, + + async deleteOwnAccount(password: string): Promise { + await request('/api/v1/user/account', { method: 'DELETE', body: JSON.stringify({ password }) }); + }, + // Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …) async exportBackupZip(): Promise { return downloadBlob('/api/v1/export?format=zip'); diff --git a/internal/api/server.go b/internal/api/server.go index 8377176..f0ac49c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -111,6 +111,8 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/export", s.handleExport) r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Post("/api/v1/import/mindsift", s.handleImportMindSift) + r.Delete("/api/v1/user/data", s.handlePurgeOwnData) + r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount) r.Post("/api/v1/import/obsidian", s.handleImportObsidian) r.Get("/api/v1/themes", s.handleGetThemes) r.Post("/api/v1/themes", s.handleSaveTheme) @@ -710,6 +712,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, + // The settings disable "Konto löschen" for the last administrator + "isLastAdmin": s.store.IsLastAdmin(userID), }) } @@ -2117,6 +2121,56 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } +// confirmPassword checks the current password sent as {"password": "…"} before destructive actions. +func (s *Server) confirmPassword(w http.ResponseWriter, r *http.Request) (*domain.User, bool) { + var req struct { + Password string `json:"password"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + user, err := s.store.GetUserByID(auth.GetUserID(r.Context())) + if err != nil || user == nil { + writeError(w, http.StatusUnauthorized, "Nicht autorisiert") + return nil, false + } + if req.Password == "" || !auth.CheckPassword(req.Password, user.PasswordHash) { + writeError(w, http.StatusForbidden, "Passwort ist falsch") + return nil, false + } + return user, true +} + +// handlePurgeOwnData deletes all notes, tasks, thoughts and lists of the caller; the account stays. +func (s *Server) handlePurgeOwnData(w http.ResponseWriter, r *http.Request) { + user, ok := s.confirmPassword(w, r) + if !ok { + return + } + if err := s.store.PurgeUserData(user.ID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + slog.Info("Benutzer hat alle eigenen Daten gelöscht", "user_id", user.ID) + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + +// handleDeleteOwnAccount deletes the caller's account with all data; never the last administrator. +func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) { + user, ok := s.confirmPassword(w, r) + if !ok { + return + } + if err := s.store.DeleteOwnAccount(user.ID); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, storage.ErrLastAdmin) { + status = http.StatusForbidden + } + writeError(w, status, err.Error()) + return + } + slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID) + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + // importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes). func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) { r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20) @@ -2143,6 +2197,14 @@ func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage return } userID := auth.GetUserID(r.Context()) + // mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is + // fully read and validated at this point, so a broken file never wipes anything. + if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") { + if err := s.store.PurgeUserData(userID); err != nil { + writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error()) + return + } + } sum, err := s.store.ApplyImport(userID, plan) if err != nil { slog.Error("Import abgebrochen", "user_id", userID, "error", err) diff --git a/internal/storage/account.go b/internal/storage/account.go new file mode 100644 index 0000000..9a0a9e2 --- /dev/null +++ b/internal/storage/account.go @@ -0,0 +1,92 @@ +package storage + +import ( + "errors" + "fmt" +) + +// ErrLastAdmin protects the server from losing its last administrator. +var ErrLastAdmin = errors.New("Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.") + +// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and +// shares), folders, shared folders, task lists and lists – in one transaction. The account, its +// settings, themes, API keys and group memberships stay. Other people's tasks in the user's task lists +// become their own personal tasks, as when a single list is deleted. +func (s *Store) PurgeUserData(userID string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + ownDocs := `(SELECT id FROM documents WHERE user_id = ?)` + ownTaskLists := `(SELECT id FROM task_lists WHERE user_id = ?)` + ownFolders := `(SELECT id FROM workspace_shared_folders WHERE owner_id = ?)` + stmts := []struct { + q string + args int // how many times userID is passed + }{ + // Task lists: keep the tasks of others, drop shares and the lists + {`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2}, + {`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1}, + {`DELETE FROM task_lists WHERE user_id = ?`, 1}, + // Tasks of others linked to blocks of the user's notes lose the link + {`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block' + AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1}, + {`DELETE FROM thoughts WHERE user_id = ?`, 1}, + // Shared folders owned by the user + {`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1}, + // Notes (history tables cascade with the document) + {`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1}, + {`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1}, + {`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1}, + {`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1}, + {`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1}, + {`DELETE FROM documents WHERE user_id = ?`, 1}, + // The user's own placement of notes, folders, pins and tag pages + {`DELETE FROM doc_user_folders WHERE user_id = ?`, 1}, + {`DELETE FROM doc_pins WHERE user_id = ?`, 1}, + {`DELETE FROM workspace_folders WHERE user_id = ?`, 1}, + {`DELETE FROM tag_pages WHERE user_id = ?`, 1}, + // Lists (items cascade) + {`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1}, + {`DELETE FROM lists WHERE user_id = ?`, 1}, + } + for _, st := range stmts { + args := make([]interface{}, st.args) + for i := range args { + args[i] = userID + } + if _, err := tx.Exec(st.q, args...); err != nil { + return fmt.Errorf("Daten löschen: %w", err) + } + } + if err := tx.Commit(); err != nil { + return err + } + s.PruneTaskAssignees() + return nil +} + +// IsLastAdmin reports whether userID is an administrator and no other one exists. +func (s *Store) IsLastAdmin(userID string) bool { + var role string + if err := s.db.QueryRow(`SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil || role != "admin" { + return false + } + var others int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND id != ?`, userID).Scan(&others) + return others == 0 +} + +// DeleteOwnAccount removes the user with all data; refused for the last administrator. +func (s *Store) DeleteOwnAccount(userID string) error { + if s.IsLastAdmin(userID) { + return ErrLastAdmin + } + return s.DeleteUser(userID) +} diff --git a/internal/storage/account_test.go b/internal/storage/account_test.go new file mode 100644 index 0000000..862143b --- /dev/null +++ b/internal/storage/account_test.go @@ -0,0 +1,92 @@ +package storage + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func TestPurgeUserDataKeepsAccountAndOthersData(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "purge.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + openSharing(t, store) + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") + blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "x"}} + + annaDoc, _ := store.CreateDocument(anna.ID, "", "Annas Notiz", "", "Ordner", blocks, nil, false) + _, _ = store.CreateDocShare(anna.ID, annaDoc.ID, ben.ID, "write") + benDoc, _ := store.CreateDocument(ben.ID, "", "Bens Notiz", "", "", blocks, nil, false) + _, _ = store.CreateDocShare(ben.ID, benDoc.ID, anna.ID, "write") + tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "") + _, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID) + bensTask, _ := store.CreateThoughtWithDetails(ben.ID, "Bens Aufgabe", "normal", "manual", "", tl.ID, "todo", "") + _, _ = store.UpdateThoughtTriage(ben.ID, bensTask.ID, "keep", "task", "", "", tl.ID) + _, _ = store.CreateThought(anna.ID, "Annas Gedanke", "normal", "manual", "") + list, _ := store.CreateList(anna.ID, "Einkauf", "", "shopping") + _, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, anna.ID) + _, _ = store.SaveUserTheme(anna.ID, "", "Wald", ":root{--accent-primary:#16a34a}") + + if err := store.PurgeUserData(anna.ID); err != nil { + t.Fatalf("PurgeUserData: %v", err) + } + + if docs, _ := store.GetDocuments(anna.ID); len(docs) != 1 || docs[0].ID != benDoc.ID { + t.Errorf("Anna should only still see Ben's shared note, got %+v", docs) + } + if th, _ := store.GetThoughts(anna.ID, "all", 100); len(th) != 0 { + t.Errorf("Anna's thoughts must be gone, got %d", len(th)) + } + if tls, _ := store.GetTaskLists(anna.ID); len(tls) != 0 { + t.Errorf("Anna's task lists must be gone") + } + if l, _ := store.GetAllListsForUser(anna.ID); len(l) != 0 { + t.Errorf("Anna's lists must be gone") + } + if u, err := store.GetUserByID(anna.ID); err != nil || u == nil { + t.Errorf("the account must stay") + } + if th, _ := store.GetUserThemes(anna.ID); len(th) != 1 { + t.Errorf("settings such as themes must stay") + } + // Ben keeps his task (now without list) and no longer sees Anna's note + task, err := store.GetThoughtByID(ben.ID, bensTask.ID) + if err != nil || task.TaskListID != "" { + t.Errorf("Ben's task must survive as personal task, got %+v (%v)", task, err) + } + if docs, _ := store.GetDocuments(ben.ID); len(docs) != 1 { + t.Errorf("Ben should only see his own note, got %d", len(docs)) + } +} + +func TestDeleteOwnAccountProtectsLastAdmin(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "account.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + admin, _ := store.CreateUser("admin@example.com", "hash", "Admin", "admin") + member, _ := store.CreateUser("member@example.com", "hash", "Mitglied", "member") + + if err := store.DeleteOwnAccount(admin.ID); !errors.Is(err, ErrLastAdmin) { + t.Fatalf("last admin must not delete the account, got %v", err) + } + if err := store.DeleteOwnAccount(member.ID); err != nil { + t.Fatalf("member deletes own account: %v", err) + } + if _, err := store.GetUserByID(member.ID); err == nil { + t.Errorf("member account should be gone") + } + second, _ := store.CreateUser("admin2@example.com", "hash", "Admin 2", "admin") + if err := store.DeleteOwnAccount(admin.ID); err != nil { + t.Fatalf("with a second admin the first may leave: %v", err) + } + if !store.IsLastAdmin(second.ID) { + t.Errorf("the remaining admin is now the last one") + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index e482122..61c43ab 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -809,6 +809,10 @@ func (s *Store) UpdateUserPassword(userID, passwordHash string) error { } func (s *Store) DeleteUser(userID string) error { + // Remove owned data properly first (other people's tasks in the user's lists stay theirs) + if err := s.PurgeUserData(userID); err != nil { + return err + } _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) _, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID) _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)