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

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