From 75246c53d4a857ccacbc424ba1c1eaba72d645ee Mon Sep 17 00:00:00 2001 From: Suman Biswas Date: Sat, 7 Nov 2026 19:19:45 +0530 Subject: [PATCH] feat: let users delete their account Server: DELETE /api/users/me removes the user; existing ON DELETE CASCADE constraints remove their messages, group memberships, devices, prekeys, refresh tokens and verifications. Any live WebSocket is closed. App: Account settings gets a "Delete account" action behind a typed username confirmation. On success it erases local keys and the chat cache, then signs out. Closes #163 --- docs/API_CONTRACTS.md | 36 ++++++++++ mobile/lib/controllers/auth_state.dart | 19 ++++++ mobile/lib/pages/settings/accounts.dart | 90 +++++++++++++++++++++++++ mobile/lib/services/api_services.dart | 2 + server/controllers/users.go | 30 +++++++++ server/controllers/users_test.go | 11 +++ server/repository/user.go | 13 ++++ server/routes/users.go | 1 + server/services/ws.go | 11 +++ 9 files changed, 213 insertions(+) diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 9620e81..6d10235 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -54,6 +54,7 @@ Every route behind the auth guard returns this when the access token is missing, | GET | [/api/users/me](#1-user-me) | Yes | Get the authenticated user's profile | | GET | [/api/users/{id}](#2-get-user-by-id) | Yes | Get a user by ID | | GET | [/api/users/search?q=&page=&limit=](#3-search-users) | Yes | Search users by username or display name (rate limited) | +| DELETE | [/api/users/me](#4-delete-account) | Yes | Permanently delete the authenticated user's account | | GET | [/api/ws?token=](#1-connect-to-websocket) | Yes (query) | WebSocket upgrade endpoint | | WS | [Client → Server](#2-client-to-server-messages) | — | Events the client sends | | WS | [Server → Client](#3-server-to-client-messages) | — | Events the server pushes | @@ -454,6 +455,41 @@ If no users match, `data` is an empty array. } ``` +### 4. Delete Account + +- **URL:** `/api/users/me` +- **Method:** `DELETE` +- **Authentication Required:** `YES` +- **curl:** `curl -X DELETE "http://HOST:PORT/api/users/me" -H "Authorization: Bearer "` + +Deletes the user. Their messages, group memberships, devices and prekeys, refresh tokens and safety-number verifications are removed by `ON DELETE CASCADE`, and any live WebSocket is closed. The access token stays valid until it expires but no longer refers to an account. + +#### `200 OK` + +```json +{ + "success": true +} +``` + +#### `404 Not Found` + +```json +{ + "success": false, + "error": "User profile not found" +} +``` + +#### `500 Internal Server Error` + +```json +{ + "success": false, + "error": "Failed to delete account" +} +``` + --- ## WebSocket Endpoints diff --git a/mobile/lib/controllers/auth_state.dart b/mobile/lib/controllers/auth_state.dart index 67adac6..9838824 100644 --- a/mobile/lib/controllers/auth_state.dart +++ b/mobile/lib/controllers/auth_state.dart @@ -262,6 +262,25 @@ class AuthState extends ChangeNotifier { notifyListeners(); } + /// Deletes the account on the server and erases this device's keys and + /// chat cache. Returns an error message, or null on success. The caller + /// then signs out as usual. + Future deleteAccount() async { + try { + await ApiService().deleteAccount(); + } catch (e) { + debugPrint("Delete account failed: $e"); + return ApiService.errorMessage( + e, + fallback: "Couldn't delete your account.", + ); + } + await SignalService().wipeLocalKeys(); + await DatabaseHelper.instance.wipeChatData(); + await _storage.delete(key: "last_user_id"); + return null; + } + void logoutSilently() { if (_token != null) { _token = null; diff --git a/mobile/lib/pages/settings/accounts.dart b/mobile/lib/pages/settings/accounts.dart index 12678ea..bd9c576 100644 --- a/mobile/lib/pages/settings/accounts.dart +++ b/mobile/lib/pages/settings/accounts.dart @@ -31,6 +31,87 @@ Future signOut(BuildContext context) async { navigator.popUntil((route) => route.isFirst); } +/// Asks the user to type their username, deletes the account and signs out. +Future _deleteAccount(BuildContext context, String username) async { + final confirmed = await showDialog( + context: context, + builder: (_) => _DeleteAccountDialog(username: username), + ); + if (confirmed != true || !context.mounted) return; + + final error = await context.read().deleteAccount(); + if (!context.mounted) return; + if (error != null) { + showSnack(context, error); + return; + } + await signOut(context); +} + +class _DeleteAccountDialog extends StatefulWidget { + final String username; + + const _DeleteAccountDialog({required this.username}); + + @override + State<_DeleteAccountDialog> createState() => _DeleteAccountDialogState(); +} + +class _DeleteAccountDialogState extends State<_DeleteAccountDialog> { + final TextEditingController _controller = TextEditingController(); + + bool get _matches => _controller.text.trim() == widget.username; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Delete account?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This permanently deletes your account, your messages and your ' + 'group memberships from the server, and erases them from this ' + 'device. It can\'t be undone.', + ), + const SizedBox(height: Insets.lg), + Text('Type ${widget.username} to confirm.'), + const SizedBox(height: Insets.sm), + TextField( + controller: _controller, + autocorrect: false, + onChanged: (_) => setState(() {}), + decoration: InputDecoration(hintText: widget.username), + ), + ], + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.colors.error, + foregroundColor: context.colors.onError, + minimumSize: const Size(88, 44), + ), + onPressed: _matches ? () => Navigator.pop(context, true) : null, + child: const Text('Delete'), + ), + ], + ); + } +} + class AccountsSettings extends StatelessWidget { const AccountsSettings({super.key}); @@ -147,6 +228,15 @@ class AccountsSettings extends StatelessWidget { if (confirmed && context.mounted) await signOut(context); }, ), + ElephantTile( + icon: Icons.delete_forever_rounded, + title: 'Delete account', + destructive: true, + showChevron: false, + onTap: user == null + ? null + : () => _deleteAccount(context, user.username), + ), ], ), ], diff --git a/mobile/lib/services/api_services.dart b/mobile/lib/services/api_services.dart index 4515d40..07b8dad 100644 --- a/mobile/lib/services/api_services.dart +++ b/mobile/lib/services/api_services.dart @@ -98,6 +98,8 @@ class ApiService { Future getMe() => _dio.get("/users/me"); + Future deleteAccount() => _dio.delete("/users/me"); + Future getUser(String userId) => _dio.get("/users/$userId"); Future searchUsers(String query, {int page = 1}) { diff --git a/server/controllers/users.go b/server/controllers/users.go index 298150a..e4431ce 100644 --- a/server/controllers/users.go +++ b/server/controllers/users.go @@ -2,6 +2,8 @@ package controllers import ( "encoding/json" + "errors" + "log" "net/http" "strconv" @@ -10,6 +12,7 @@ import ( "github.com/commandlinecoding/elephant/server/repository" "github.com/commandlinecoding/elephant/server/services" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" ) func HandleUserSearch(w http.ResponseWriter, r *http.Request) { @@ -61,6 +64,33 @@ func HandleGetMe(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: user}) } +func HandleDeleteMe(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + uid, ok := r.Context().Value(middlewares.UserIDKey).(string) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Unauthorized context"}) + return + } + + repo := repository.NewUserRepository() + if err := repo.DeleteUser(r.Context(), uid); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "User profile not found"}) + return + } + log.Printf("delete account %s: %v", uid, err) + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to delete account"}) + return + } + + services.Hub.Disconnect(uid) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true}) +} + func HandleGetUserByID(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/server/controllers/users_test.go b/server/controllers/users_test.go index 498ba7f..48ff3e4 100644 --- a/server/controllers/users_test.go +++ b/server/controllers/users_test.go @@ -42,6 +42,17 @@ func TestHandleGetMe_Unauthorized(t *testing.T) { } } +func TestHandleDeleteMe_Unauthorized(t *testing.T) { + req := httptest.NewRequest("DELETE", "/api/users/me", nil) + w := httptest.NewRecorder() + + HandleDeleteMe(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d", w.Code) + } +} + func TestHandleGetMe_SuccessContext(t *testing.T) { req := httptest.NewRequest("GET", "/api/users/me", nil) diff --git a/server/repository/user.go b/server/repository/user.go index 33c9586..e21b502 100644 --- a/server/repository/user.go +++ b/server/repository/user.go @@ -176,3 +176,16 @@ func (r *UserRepository) CreateOAuthUser(ctx context.Context, username, email, d } return &u, nil } + +// DeleteUser removes the account. ON DELETE CASCADE removes its messages, +// group memberships, devices, prekeys, refresh tokens and verifications. +func (r *UserRepository) DeleteUser(ctx context.Context, id string) error { + tag, err := config.DB.Exec(ctx, `DELETE FROM users WHERE id = $1;`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} diff --git a/server/routes/users.go b/server/routes/users.go index 7dfece1..0e7ccfa 100644 --- a/server/routes/users.go +++ b/server/routes/users.go @@ -12,6 +12,7 @@ func UserRoute(router chi.Router) { router.Use(middlewares.AuthGuard) router.Get("/me", controllers.HandleGetMe) + router.Delete("/me", controllers.HandleDeleteMe) router.With(searchLimiter.Limit).Get("/search", controllers.HandleUserSearch) router.Get("/{id}", controllers.HandleGetUserByID) } diff --git a/server/services/ws.go b/server/services/ws.go index 2e521a7..f0db365 100644 --- a/server/services/ws.go +++ b/server/services/ws.go @@ -76,6 +76,17 @@ func (h *WSHub) Run() { } } +// Disconnect closes the user's live WebSocket, if any. ReadPump then +// unregisters the client as usual. +func (h *WSHub) Disconnect(userID string) { + h.mu.RLock() + client, ok := h.clients[strings.ToLower(userID)] + h.mu.RUnlock() + if ok { + _ = client.Conn.Close() + } +} + func (c *WSClient) ReadPump() { defer func() { Hub.Unregister <- c