feat: account deletion, username reuse, and real-time username availability

- Add account deletion with tombstone (isDeleted flag), password/username
  confirmation, owned-space guard, and full cleanup transaction
- Free deleted usernames by renaming to !deleted:<id> so they can be reused
- Add migration to retroactively free usernames from already-tombstoned users
- Add GET /api/auth/check-username endpoint with rate limiting for real-time
  availability checking during registration
- Add debounced username availability indicator on registration Step 1
- Add DeleteAccountModal with federation-aware remote account cleanup
- Add federation ops utility for remote instance management
- Update sanitizeUser to anonymize deleted user profiles
- Add instance store improvements and connected instances modal updates
This commit is contained in:
Jannis Braun
2026-03-11 16:29:25 +01:00
parent c8e2945c07
commit 8c8767ba2c
18 changed files with 1343 additions and 21 deletions
+69 -1
View File
@@ -532,6 +532,65 @@ class ConnectionManager {
}
}
/** Force-disconnect all WebSocket connections for a user (e.g. account deletion). */
forceDisconnectUser(userId: string): void {
// Cancel any pending offline timeout
const timeout = this.pendingOfflineTimeouts.get(userId);
if (timeout) {
clearTimeout(timeout);
this.pendingOfflineTimeouts.delete(userId);
}
// Leave voice room if in one
const left = this.leaveCurrentRoom(userId);
this.clearVoiceUserStatus(userId);
if (left) {
if (left.room.roomType === 'space') {
const meta = left.room.metadata as SpaceRoomMeta;
this.sendToSpace(meta.spaceId, {
type: 'voice_state_update',
channelId: left.roomId,
userId,
action: 'leave',
});
} else {
this.sendToDmMembers(left.roomId, {
type: 'voice_state_update',
channelId: left.roomId,
userId,
action: 'leave',
});
}
}
// Destroy any ringing DM rooms where this user is the caller
for (const [roomId, room] of this.voiceRooms) {
if (room.roomType === 'dm') {
const meta = room.metadata as DmRoomMeta;
if (meta.state === 'ringing' && meta.callerId === userId) {
this.destroyRoom(roomId);
this.sendToDmMembers(roomId, {
type: 'dm_call_ended',
dmChannelId: roomId,
});
}
}
}
// Close all WebSocket connections
const connections = this.connections.get(userId);
if (connections) {
for (const ws of connections) {
this.wsToUser.delete(ws);
try { ws.close(4001, 'Account deleted'); } catch { /* ignore */ }
}
this.connections.delete(userId);
}
// Clean up user spaces
this.userSpaces.delete(userId);
}
getAllOnlineUserIds(): string[] {
return Array.from(this.connections.keys());
}
@@ -991,11 +1050,20 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
const payload = verifyJwt(parsed.token);
userId = payload.userId;
username = payload.username;
// Reject deleted users
const db = getDb();
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!userRow || userRow.isDeleted) {
ws.send(JSON.stringify({ type: 'error', message: 'This account has been deleted' }));
ws.close();
return;
}
authenticated = true;
clearTimeout(authTimeout);
// Update user status to online
const db = getDb();
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, userId)).run();
// Add connection