feat(account): danger zone, restore import, larger API key field on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m27s

- 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:13:29 +01:00
1 parent 9bc8716f41
commit 0e9fd1b3ad
11 files changed
+472 -15

No files matched your search

+92
View File
@@ -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)
}