added option to delete own account
authorPhiTux <redacted>
Sat, 19 Jul 2025 15:00:00 +0000 (17:00 +0200)
committerPhiTux <redacted>
Sat, 19 Jul 2025 15:00:00 +0000 (17:00 +0200)
backend/handlers/users.go
backend/main.go
backend/utils/file_handling.go
frontend/src/routes/(authed)/+layout.svelte
frontend/src/routes/login/+page.svelte

index 8f2f75d7353e4082971f60108ee7afb95ac0118b..c84ddd17c276a60c7db8f4a10db01a852b06c248 100644 (file)
@@ -856,3 +856,122 @@ func ChangePassword(w http.ResponseWriter, r *http.Request) {
                "message": "Password changed successfully",
        })
 }
+
+type DeleteAccountRequest struct {
+       Password string `json:"password"`
+}
+
+func DeleteAccount(w http.ResponseWriter, r *http.Request) {
+       // Get user ID from context
+       userID, ok := r.Context().Value(utils.UserIDKey).(int)
+       if !ok {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": "User not authenticated",
+               })
+               return
+       }
+
+       // Parse request body
+       var req DeleteAccountRequest
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               utils.JSONResponse(w, http.StatusBadRequest, map[string]any{
+                       "success": false,
+                       "message": "Invalid request body",
+               })
+               return
+       }
+
+       // Check if password is correct
+       users, err := utils.GetUsers()
+       if err != nil {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": fmt.Sprintf("Error retrieving users: %v", err),
+               })
+               return
+       }
+       usersList, ok := users["users"].([]any)
+       if !ok {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": "Users data is not in the correct format",
+               })
+               return
+       }
+
+       var user map[string]any
+       for _, u := range usersList {
+               uMap, ok := u.(map[string]any)
+               if !ok {
+                       continue
+               }
+               if id, ok := uMap["user_id"].(float64); ok && int(id) == userID {
+                       user = uMap
+                       break
+               }
+       }
+
+       if user == nil {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": "User not found",
+               })
+               return
+       }
+
+       password, ok := user["password"].(string)
+       if !ok {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": "Current hashed password not found for user",
+               })
+               return
+       }
+
+       if !utils.VerifyPassword(req.Password, password) {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success":            false,
+                       "message":            "Password is incorrect",
+                       "password_incorrect": true,
+               })
+               return
+       }
+
+       // Remove user from users list
+       var newUsersList []any
+       for _, u := range usersList {
+               uMap, ok := u.(map[string]any)
+               if !ok {
+                       continue
+               }
+               // Keep all users, except the one with the same user_id
+               if id, ok := uMap["user_id"].(float64); !ok || int(id) != userID {
+                       utils.Logger.Printf("Keeping user with ID %f (%d)", id, userID)
+                       newUsersList = append(newUsersList, u)
+               }
+       }
+       users["users"] = newUsersList
+
+       if err := utils.WriteUsers(users); err != nil {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": fmt.Sprintf("Error writing users data: %v", err),
+               })
+               return
+       }
+
+       // Delete directory of the user with all his data
+       if err := utils.DeleteUserData(userID); err != nil {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": fmt.Sprintf("Error deleting user data of ID %d (account already deleted): %v", userID, err),
+               })
+               utils.Logger.Printf("Error deleting user data of ID %d (You can savely delete the directory with the same id): %v", userID, err)
+               return
+       }
+
+       utils.JSONResponse(w, http.StatusOK, map[string]any{
+               "success": true,
+       })
+}
index 16c5ab02c51c04fb9daf4dd2418e04dec0d50a1b..d308d8109ca3d953ae0a27cc736511977db78c3f 100644 (file)
@@ -40,6 +40,7 @@ func main() {
        mux.HandleFunc("GET /users/getUserSettings", middleware.RequireAuth(handlers.GetUserSettings))
        mux.HandleFunc("POST /users/saveUserSettings", middleware.RequireAuth(handlers.SaveUserSettings))
        mux.HandleFunc("POST /users/changePassword", middleware.RequireAuth(handlers.ChangePassword))
+       mux.HandleFunc("POST /users/deleteAccount", middleware.RequireAuth(handlers.DeleteAccount))
 
        mux.HandleFunc("POST /logs/saveLog", middleware.RequireAuth(handlers.SaveLog))
        mux.HandleFunc("GET /logs/getLog", middleware.RequireAuth(handlers.GetLog))
index 07a0e1fd7b9769a8824e120850ba8f89e69ff882..979bd82e6536bf416f1ee1d7834d9d1244167a2a 100644 (file)
@@ -447,3 +447,14 @@ func GetMonths(userID int, year string) ([]string, error) {
 
        return months, nil
 }
+
+func DeleteUserData(userID int) error {
+       // Try to remove the user directory
+       dirPath := filepath.Join(Settings.DataPath, strconv.Itoa(userID))
+       if err := os.RemoveAll(dirPath); err != nil {
+               Logger.Printf("Error removing directory %s: %v", dirPath, err)
+               return fmt.Errorf("internal server error when trying to remove user data for ID %d", userID)
+       }
+
+       return nil
+}
index 46448685cdf78c5c6cbdf8db5c3fb0a90359b015..503da4869efadeb8447eb01751fd878265ac37e5 100644 (file)
@@ -57,7 +57,6 @@
                });
 
                document.getElementById('settingsModal').addEventListener('shown.bs.modal', function () {
-                       console.log("triggered 'shown.bs.modal' event");
                        const height = document.getElementById('modal-body').clientHeight;
                        document.getElementById('settings-content').style.height = 'calc(' + height + 'px - 2rem)';
                        document.getElementById('settings-nav').style.height = 'calc(' + height + 'px - 2rem)';
                });
        });
 
-       function logout() {
+       function logout(errorCode) {
                axios
                        .get(API_URL + '/users/logout')
                        .then((response) => {
                                localStorage.removeItem('user');
-                               goto('/login');
+                               if (errorCode) {
+                                       goto(`/login?error=${errorCode}`);
+                               } else {
+                                       goto('/login');
+                               }
                        })
                        .catch((error) => {
                                console.error(error);
                                isChangingPassword = false;
                        });
        }
+
+       let showConfirmDeleteAccount = $state(false);
+       let deleteAccountPassword = $state('');
+       let isDeletingAccount = $state(false);
+       let deleteAccountPasswordIncorrect = $state(false);
+       let showDeleteAccountSuccess = $state(false);
+
+       function deleteAccount() {
+               if (isDeletingAccount) return;
+               isDeletingAccount = true;
+
+               axios
+                       .post(API_URL + '/users/deleteAccount', {
+                               password: deleteAccountPassword
+                       })
+                       .then((response) => {
+                               if (response.data.success) {
+                                       showDeleteAccountSuccess = true;
+
+                                       // close modal
+                                       settingsModal.hide();
+
+                                       logout(410); // HTTP 410 Gone => Account deleted
+                               } else if (response.data.password_incorrect) {
+                                       deleteAccountPasswordIncorrect = true;
+                               } else {
+                                       console.error('Error deleting account');
+                                       console.error(response.data);
+                               }
+                       })
+                       .catch((error) => {
+                               console.error(error);
+                               deleteAccountPasswordIncorrect = true;
+                       })
+                       .finally(() => {
+                               isDeletingAccount = false;
+                               showConfirmDeleteAccount = false;
+                               deleteAccountPassword = '';
+                       });
+       }
 </script>
 
 <div class="d-flex flex-column h-100">
                                <button class="btn btn-outline-secondary me-2" onclick={openSettingsModal}
                                        ><Fa icon={faSliders} /></button
                                >
-                               <button class="btn btn-outline-secondary" onclick={logout}
+                               <button class="btn btn-outline-secondary" onclick={logout(null)}
                                        ><Fa icon={faRightFromBracket} /></button
                                >
                        </div>
                                                                                                deleteTag={askDeleteTag}
                                                                                        />
                                                                                        {#if deleteTagId === tag.id}
-                                                                                               <div class="alert alert-danger align-items-center" role="alert">
+                                                                                               <div
+                                                                                                       class="alert alert-danger align-items-center"
+                                                                                                       role="alert"
+                                                                                                       transition:slide
+                                                                                               >
                                                                                                        <div>
                                                                                                                <Fa icon={faTriangleExclamation} fw /> <b>Tag dauerhaft löschen?</b>
                                                                                                                Dies kann einen Moment dauern, da jeder Eintrag nach potenziellen Verlinkungen
                                                                </div>
                                                                <div id="backupkeys"><h5>Backup-Keys</h5></div>
                                                                <div id="username"><h5>Username ändern</h5></div>
-                                                               <div id="deleteaccount"><h5>Konto löschen</h5></div>
+                                                               <div id="deleteaccount">
+                                                                       <h5>Konto löschen</h5>
+                                                                       <p>
+                                                                               Dies löscht dein Konto und alle damit verbundenen Daten. Dies kann nicht
+                                                                               rückgängig gemacht werden!
+                                                                       </p>
+                                                                       <form
+                                                                               onsubmit={() => {
+                                                                                       showConfirmDeleteAccount = true;
+                                                                               }}
+                                                                       >
+                                                                               <div class="form-floating mb-3">
+                                                                                       <input
+                                                                                               type="password"
+                                                                                               class="form-control"
+                                                                                               id="currentPassword"
+                                                                                               placeholder="Aktuelles Passwort"
+                                                                                               bind:value={deleteAccountPassword}
+                                                                                       />
+                                                                                       <label for="currentPassword">Passwort bestätigen</label>
+                                                                               </div>
+                                                                               <button
+                                                                                       class="btn btn-danger"
+                                                                                       onclick={() => {
+                                                                                               showConfirmDeleteAccount = true;
+                                                                                       }}
+                                                                                       data-sveltekit-noscroll
+                                                                               >
+                                                                                       Konto löschen
+                                                                                       {#if isDeletingAccount}
+                                                                                               <!-- svelte-ignore a11y_no_static_element_interactions -->
+                                                                                               <div class="spinner-border" role="status">
+                                                                                                       <span class="visually-hidden">Loading...</span>
+                                                                                               </div>
+                                                                                       {/if}
+                                                                               </button>
+                                                                       </form>
+                                                                       {#if showDeleteAccountSuccess}
+                                                                               <div class="alert alert-success mt-2" role="alert" transition:slide>
+                                                                                       Dein Konto wurde erfolgreich gelöscht!<br />
+                                                                                       Du solltest jetzt eigentlich automatisch ausgeloggt werden. Falls nicht, dann logge
+                                                                                       dich bitte sebst aus.
+                                                                               </div>
+                                                                       {/if}
+                                                                       {#if deleteAccountPasswordIncorrect}
+                                                                               <div class="alert alert-danger mt-2" role="alert" transition:slide>
+                                                                                       Das eingegebene Passwort ist falsch!
+                                                                               </div>
+                                                                       {/if}
+                                                                       {#if showConfirmDeleteAccount}
+                                                                               <div class="alert alert-danger mt-2" role="alert" transition:slide>
+                                                                                       Bist du dir sicher, dass du dein Konto löschen möchtest? Dies kann nicht
+                                                                                       rückgängig gemacht werden!
+                                                                                       <div class="d-flex flex-row mt-2">
+                                                                                               <button
+                                                                                                       class="btn btn-secondary"
+                                                                                                       onclick={() => {
+                                                                                                               showConfirmDeleteAccount = false;
+                                                                                                               deleteAccountPassword = '';
+                                                                                                       }}>Abbrechen</button
+                                                                                               >
+                                                                                               <button
+                                                                                                       class="btn btn-danger ms-3"
+                                                                                                       onclick={deleteAccount}
+                                                                                                       disabled={isDeletingAccount}
+                                                                                                       >Löschen bestätigen
+                                                                                                       {#if isDeletingAccount}
+                                                                                                               <span
+                                                                                                                       class="spinner-border spinner-border-sm ms-2"
+                                                                                                                       role="status"
+                                                                                                                       aria-hidden="true"
+                                                                                                               ></span>
+                                                                                                       {/if}
+                                                                                               </button>
+                                                                                       </div>
+                                                                               </div>
+                                                                       {/if}
+                                                               </div>
                                                        </div>
 
                                                        <div id="about">
index 52c3e3362c21361c9f96fd6bd24c244905a8f2ff..670bfc6de32d4382da33089e974cab265f5f2e77 100644 (file)
@@ -47,6 +47,9 @@
                } else if (window.location.search.includes('error=401')) {
                        const toast = new bootstrap.Toast(document.getElementById('toastLoginInvalid'));
                        toast.show();
+               } else if (window.location.search.includes('error=410')) {
+                       const toast = new bootstrap.Toast(document.getElementById('toastAccountDeleted'));
+                       toast.show();
                }
 
                // check if registration is allowed
                                                                                </div>
                                                                        {/if}
 
-                                                                       Fortschritt:
+                                                                       <u>Fortschritt:</u>
                                                                        <div class="progress-item {active_phase >= 0 ? 'active' : ''}">
                                                                                <div class="d-flex">
                                                                                        <div class="emoji">
                                <div class="toast-body">Authentifizierung fehlgeschlagen. Bitte neu anmelden.</div>
                        </div>
                </div>
+
+               <div
+                       id="toastAccountDeleted"
+                       class="toast align-items-center text-bg-success"
+                       role="alert"
+                       aria-live="assertive"
+                       aria-atomic="true"
+               >
+                       <div class="d-flex">
+                               <div class="toast-body">Account erfolgreich gelöscht.</div>
+                       </div>
+               </div>
        </div>
 </div>
 
git clone https://git.99rst.org/PROJECT