Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/API_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 <access_token>"`

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
Expand Down
19 changes: 19 additions & 0 deletions mobile/lib/controllers/auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?> 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;
Expand Down
90 changes: 90 additions & 0 deletions mobile/lib/pages/settings/accounts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,87 @@ Future<void> signOut(BuildContext context) async {
navigator.popUntil((route) => route.isFirst);
}

/// Asks the user to type their username, deletes the account and signs out.
Future<void> _deleteAccount(BuildContext context, String username) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => _DeleteAccountDialog(username: username),
);
if (confirmed != true || !context.mounted) return;

final error = await context.read<AuthState>().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});

Expand Down Expand Up @@ -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),
),
],
),
],
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/services/api_services.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class ApiService {

Future<Response> getMe() => _dio.get("/users/me");

Future<Response> deleteAccount() => _dio.delete("/users/me");

Future<Response> getUser(String userId) => _dio.get("/users/$userId");

Future<Response> searchUsers(String query, {int page = 1}) {
Expand Down
30 changes: 30 additions & 0 deletions server/controllers/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package controllers

import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"

Expand All @@ -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) {
Expand Down Expand Up @@ -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")

Expand Down
11 changes: 11 additions & 0 deletions server/controllers/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
13 changes: 13 additions & 0 deletions server/repository/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions server/routes/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions server/services/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading