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
+44 -1
View File
@@ -23,6 +23,7 @@ interface CachedInstanceToken {
token: string;
label: string;
username: string;
pendingPasswordSync?: boolean;
}
const STORAGE_KEY_PREFIX = 'backspace_instances';
@@ -57,8 +58,10 @@ function loadCachedTokens(userId: string): Record<string, CachedInstanceToken> {
}
}
function saveCachedTokens(instances: ConnectedInstance[], userId: string): void {
function saveCachedTokens(instances: ConnectedInstance[], userId: string, pendingSyncFlags?: Record<string, boolean>): void {
const cache: Record<string, CachedInstanceToken> = {};
// Load existing cache to preserve pendingPasswordSync flags
const existing = loadCachedTokens(userId);
for (const inst of instances) {
// Skip tokenless placeholders — writing an empty token would cause
// autoConnectAll to find a truthy cached entry with an empty bearer token
@@ -67,6 +70,7 @@ function saveCachedTokens(instances: ConnectedInstance[], userId: string): void
token: inst.token,
label: inst.label,
username: inst.username,
pendingPasswordSync: pendingSyncFlags?.[inst.origin] ?? existing[inst.origin]?.pendingPasswordSync,
};
}
localStorage.setItem(storageKey(userId), JSON.stringify(cache));
@@ -125,6 +129,9 @@ interface InstanceState {
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
reconnectInstance: (origin: string) => Promise<void>;
reauthenticateInstance: (origin: string, password: string) => Promise<void>;
updateInstanceToken: (origin: string, newToken: string) => void;
setPendingPasswordSync: (origin: string, pending: boolean) => void;
hasPendingPasswordSync: (origin: string) => boolean;
syncInstanceList: () => Promise<void>;
autoConnectAll: () => Promise<void>;
reset: () => void;
@@ -423,6 +430,42 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
password,
currentUser?.displayName || undefined,
);
// Clear pending password sync — connectToRemote uses the current password
// which updates the remote's stored hash through register/login
get().setPendingPasswordSync(origin, false);
},
updateInstanceToken: (origin: string, newToken: string) => {
set((state) => ({
instances: state.instances.map(i => {
if (i.origin !== origin) return i;
// Recreate API client with new token
const newApi = createApiClient(origin, () => newToken);
return { ...i, token: newToken, api: newApi };
}),
}));
const userId = useAuthStore.getState().user?.id;
if (userId) saveCachedTokens(get().instances, userId);
// Reconnect WebSocket with new token
disconnectInstance(origin);
connectInstance(origin, newToken);
},
setPendingPasswordSync: (origin: string, pending: boolean) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const flags: Record<string, boolean> = { [origin]: pending };
saveCachedTokens(get().instances, userId, flags);
},
hasPendingPasswordSync: (origin: string) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return false;
const cached = loadCachedTokens(userId);
return cached[origin]?.pendingPasswordSync === true;
},
syncInstanceList: async () => {