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
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:
1 parent
9bc8716f41
commit
0e9fd1b3ad
11 files changed
+472
-15
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user