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:
@@ -7,6 +7,7 @@ import { useSocialStore } from './socialStore';
|
||||
import { useVoiceStore } from './voiceStore';
|
||||
import { useInstanceStore } from './instanceStore';
|
||||
import { syncProfileUpdateToRemotes } from '../utils/profileSync';
|
||||
import { changePasswordOnRemotes, deleteAccountOnRemotes, type FederationOpResult } from '../utils/federationOps';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
@@ -18,6 +19,8 @@ interface AuthState {
|
||||
logout: () => void;
|
||||
loadUser: () => Promise<void>;
|
||||
updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<FederationOpResult[]>;
|
||||
deleteAccount: (password: string, username: string) => Promise<void>;
|
||||
setUser: (user: User) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
@@ -103,6 +106,32 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
changePassword: async (currentPassword: string, newPassword: string) => {
|
||||
// Change on home instance
|
||||
const response = await api.users.changePassword({ currentPassword, newPassword });
|
||||
|
||||
// Update token in state and localStorage
|
||||
localStorage.setItem('backspace_token', response.token);
|
||||
set({ token: response.token });
|
||||
|
||||
// Propagate to remote instances (best-effort)
|
||||
const remoteResults = await changePasswordOnRemotes(newPassword);
|
||||
return remoteResults;
|
||||
},
|
||||
|
||||
deleteAccount: async (password: string, username: string) => {
|
||||
// Delete on all remote instances first (best-effort)
|
||||
await deleteAccountOnRemotes();
|
||||
|
||||
// Delete on home instance
|
||||
await api.users.deleteAccount({ password, username });
|
||||
|
||||
// Clear all state
|
||||
localStorage.removeItem('backspace_token');
|
||||
resetUserStores();
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
|
||||
setUser: (user: User) => set({ user }),
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user