From 8c8767ba2ca6152550c5c76a889f87e22db35df9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:29:25 +0100 Subject: [PATCH] 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: 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 --- packages/server/src/db/migrate.ts | 24 ++ packages/server/src/db/schema.ts | 1 + packages/server/src/index.ts | 6 + packages/server/src/routes/auth.ts | 48 ++- packages/server/src/routes/spaces.ts | 48 +++ packages/server/src/routes/users.ts | 144 ++++++- packages/server/src/utils/sanitize.ts | 22 + packages/server/src/ws/handler.ts | 70 ++- packages/shared/src/types.ts | 15 + packages/web/src/api/client.ts | 36 ++ .../web/src/components/auth/LoginPage.tsx | 42 +- .../web/src/components/auth/RegisterPage.tsx | 146 ++++++- .../components/modals/ConnectedInstances.tsx | 6 + .../components/modals/DeleteAccountModal.tsx | 398 ++++++++++++++++++ .../modals/settingsPanels/AccountPanel.tsx | 180 ++++++++ packages/web/src/stores/authStore.ts | 29 ++ packages/web/src/stores/instanceStore.ts | 45 +- packages/web/src/utils/federationOps.ts | 104 +++++ 18 files changed, 1343 insertions(+), 21 deletions(-) create mode 100644 packages/web/src/components/modals/DeleteAccountModal.tsx create mode 100644 packages/web/src/utils/federationOps.ts diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index cc173420..255ddef3 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -99,6 +99,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'avatar_color', type: 'TEXT' }, ] + }, + { + name: 'users', + columns: [ + { name: 'is_deleted', type: 'INTEGER DEFAULT 0' }, + ] } ]; @@ -192,6 +198,9 @@ export function runMigrations(db: Database.Database): void { // ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─ migrateCorruptedReadStates(db); + // ─── Free usernames from already-tombstoned users ─────────────────────────── + migrateDeletedUsernames(db); + console.log('Migrations complete.'); } @@ -418,6 +427,21 @@ function migrateCorruptedReadStates(db: Database.Database): void { } } +/** Rename already-tombstoned users so their original username can be reused */ +function migrateDeletedUsernames(db: Database.Database): void { + const rows = db.prepare( + "SELECT id, username FROM users WHERE is_deleted = 1 AND username NOT LIKE '!deleted:%'" + ).all() as { id: string; username: string }[]; + + if (rows.length === 0) return; + + const update = db.prepare('UPDATE users SET username = ? WHERE id = ?'); + for (const row of rows) { + update.run(`!deleted:${row.id}`, row.id); + console.log(`Migrating: Freed username "${row.username}" from deleted user ${row.id}`); + } +} + /** * Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net" * Frees plain usernames for native user creation and makes all federated users diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 5af4358d..07f1bd5a 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -16,6 +16,7 @@ export const users = sqliteTable('users', { accentColor: text('accent_color'), avatarColor: text('avatar_color'), bio: text('bio'), + isDeleted: integer('is_deleted').default(0), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cb51917c..73e35300 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -43,6 +43,12 @@ async function main(): Promise { max: 200, timeWindow: '1 minute', keyGenerator: (request) => (request as any).userId || request.ip, + errorResponseBuilder: (_request, context) => ({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded', + retryAfter: Math.ceil(context.ttl / 1000), + }), }); await app.register(websocket); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 60814b0b..d33a4e8c 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -13,7 +13,7 @@ export async function authRoutes(app: FastifyInstance): Promise { config: { rateLimit: { max: 10, - timeWindow: '15 minutes', + timeWindow: '2 minutes', keyGenerator: (request: any) => request.ip, }, }, @@ -128,11 +128,49 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(201).send(response); }); + app.get<{ Querystring: { username?: string } }>('/api/auth/check-username', { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + keyGenerator: (request: any) => request.ip, + }, + }, + }, async (request, reply) => { + const raw = request.query.username; + if (!raw || typeof raw !== 'string') { + return reply.code(400).send({ available: false, reason: 'Username is required' }); + } + + const trimmed = raw.trim(); + + // Format validation (same rules as registration) + if (trimmed.length < 3 || trimmed.length > 32) { + return reply.code(200).send({ available: false, reason: 'Username must be between 3 and 32 characters' }); + } + if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { + return reply.code(200).send({ available: false, reason: 'Username can only contain letters, numbers, and underscores' }); + } + + // Check registration is open + const db = getDb(); + const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined + ? instanceRow.registrationOpen === 1 + : config.registrationOpen; + if (!registrationOpen) { + return reply.code(403).send({ available: false, reason: 'Registration is currently closed' }); + } + + const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmed)).get(); + return reply.code(200).send({ available: !existing }); + }); + app.post<{ Body: LoginRequest }>('/api/auth/login', { config: { rateLimit: { - max: 10, - timeWindow: '15 minutes', + max: 15, + timeWindow: '2 minutes', keyGenerator: (request: any) => request.ip, }, }, @@ -154,6 +192,10 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); } + if (user.isDeleted) { + return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 }); + } + const validPassword = await verifyPassword(password, user.passwordHash); if (!validPassword) { return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 1d943ffc..9c275f42 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -953,6 +953,54 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ success: true }); }); + // PATCH /api/spaces/:id/transfer-ownership — Transfer space ownership + app.patch<{ Params: { id: string }; Body: { newOwnerId: string } }>('/api/spaces/:id/transfer-ownership', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { newOwnerId } = request.body; + const db = getDb(); + + if (!newOwnerId || typeof newOwnerId !== 'string') { + return reply.code(400).send({ error: 'newOwnerId is required', statusCode: 400 }); + } + + const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); + } + + if (!isSpaceOwner(id, request.userId)) { + return reply.code(403).send({ error: 'Only the space owner can transfer ownership', statusCode: 403 }); + } + + if (newOwnerId === request.userId) { + return reply.code(400).send({ error: 'You are already the owner', statusCode: 400 }); + } + + // Verify new owner is a member + if (!isMember(id, newOwnerId)) { + return reply.code(400).send({ error: 'New owner must be a member of the space', statusCode: 400 }); + } + + db.update(schema.spaces).set({ ownerId: newOwnerId }).where(eq(schema.spaces.id, id)).run(); + + const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); + if (!updated) { + return reply.code(500).send({ error: 'Failed to transfer ownership', statusCode: 500 }); + } + + const spaceData = rowToSpace(updated); + + // Broadcast space_updated so all clients see the new owner + connectionManager.sendToSpace(id, { + type: 'space_updated', + space: spaceData, + }); + + return reply.code(200).send(spaceData); + }); + // ─── Ban Management ─────────────────────────────────────────────────────── // GET /api/spaces/:id/bans - List bans diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 46efe844..266b5d58 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -1,9 +1,10 @@ import type { FastifyInstance } from 'fastify'; -import { eq, or, inArray } from 'drizzle-orm'; +import { eq, or, and, inArray } from 'drizzle-orm'; +import crypto from 'crypto'; import { getDb, schema } from '../db/index.js'; -import { authenticate, verifyPassword } from '../utils/auth.js'; +import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js'; import { connectionManager } from '../ws/handler.js'; -import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ReplicatedInstance } from '@backspace/shared'; +import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; @@ -12,8 +13,8 @@ export async function userRoutes(app: FastifyInstance): Promise { const db = getDb(); const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); - if (!user) { - return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + if (!user || user.isDeleted) { + return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 }); } return reply.code(200).send(sanitizeUser(user)); @@ -38,6 +39,139 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(200).send(response); }); + // POST /api/users/@me/change-password — change account password + app.post<{ Body: ChangePasswordRequest }>('/api/users/@me/change-password', { + preHandler: authenticate, + config: { rateLimit: { max: 5, timeWindow: '15 minutes' } }, + }, async (request, reply) => { + const { currentPassword, newPassword } = request.body; + + if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) { + return reply.code(400).send({ error: 'New password must be at least 6 characters', statusCode: 400 }); + } + + const db = getDb(); + const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!user) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + // Native users (no homeInstance) must provide current password + if (!user.homeInstance) { + if (!currentPassword || typeof currentPassword !== 'string') { + return reply.code(400).send({ error: 'Current password is required', statusCode: 400 }); + } + const valid = await verifyPassword(currentPassword, user.passwordHash); + if (!valid) { + return reply.code(403).send({ error: 'Incorrect password', statusCode: 403 }); + } + } + // Federated users: JWT auth is sufficient — skip old password verification + + const newHash = await hashPassword(newPassword); + db.update(schema.users).set({ passwordHash: newHash }).where(eq(schema.users.id, request.userId)).run(); + + // Issue fresh JWT + const token = signJwt({ userId: user.id, username: user.username }); + const response: ChangePasswordResponse = { token }; + return reply.code(200).send(response); + }); + + // DELETE /api/users/@me — delete (tombstone) account + app.delete<{ Body: DeleteAccountRequest }>('/api/users/@me', { + preHandler: authenticate, + config: { rateLimit: { max: 3, timeWindow: '15 minutes' } }, + }, async (request, reply) => { + const { password, username } = request.body; + + if (!username || typeof username !== 'string') { + return reply.code(400).send({ error: 'Username confirmation is required', statusCode: 400 }); + } + + const db = getDb(); + const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!user) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + // Verify username matches (confirmation safeguard) + if (user.username !== username) { + return reply.code(400).send({ error: 'Username does not match', statusCode: 400 }); + } + + // Native users must verify password; federated users rely on JWT auth + if (!user.homeInstance) { + if (!password || typeof password !== 'string') { + return reply.code(400).send({ error: 'Password is required', statusCode: 400 }); + } + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) { + return reply.code(403).send({ error: 'Incorrect password', statusCode: 403 }); + } + } + + // Check if user owns any spaces + const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name }) + .from(schema.spaces) + .where(eq(schema.spaces.ownerId, request.userId)) + .all(); + if (ownedSpaces.length > 0) { + return reply.code(400).send({ + error: 'You must transfer ownership or delete all spaces you own before deleting your account', + statusCode: 400, + ownedSpaces, + }); + } + + // Run all cleanup in a single transaction + db.transaction((tx) => { + const uid = request.userId; + + // Remove from spaces, roles, friends, DMs, read states, reactions, folders, bans, join requests, voice restrictions, channel overrides + tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run(); + tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run(); + tx.delete(schema.friends).where(or(eq(schema.friends.userId, uid), eq(schema.friends.friendId, uid))).run(); + tx.delete(schema.friendRequests).where(or(eq(schema.friendRequests.fromId, uid), eq(schema.friendRequests.toId, uid))).run(); + tx.delete(schema.dmMembers).where(eq(schema.dmMembers.userId, uid)).run(); + tx.delete(schema.readStates).where(eq(schema.readStates.userId, uid)).run(); + tx.delete(schema.reactions).where(eq(schema.reactions.userId, uid)).run(); + tx.delete(schema.dmReactions).where(eq(schema.dmReactions.userId, uid)).run(); + tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run(); + + // Conditional deletes for tables that may reference userId + try { tx.delete(schema.bans).where(eq(schema.bans.userId, uid)).run(); } catch { /* table may not exist */ } + try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ } + try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ } + + // Remove member-type channel overrides for this user + tx.delete(schema.channelOverrides).where( + and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid)) + ).run(); + + // Tombstone user row — rename username to free it for reuse + tx.update(schema.users).set({ + username: `!deleted:${uid}`, + passwordHash: crypto.randomBytes(32).toString('hex'), // unusable random string + displayName: null, + avatar: null, + banner: null, + bio: null, + customStatus: null, + accentColor: null, + avatarColor: null, + replicatedInstances: '[]', + isDeleted: 1, + status: 'offline', + isAdmin: 0, + }).where(eq(schema.users.id, uid)).run(); + }); + + // Force-close all WebSocket connections + connectionManager.forceDisconnectUser(request.userId); + + return reply.code(200).send({ success: true }); + }); + app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body; const db = getDb(); diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index 7b75f991..af4dbe05 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -2,6 +2,28 @@ import type { User, ReplicatedInstance } from '@backspace/shared'; import { schema } from '../db/index.js'; export function sanitizeUser(row: typeof schema.users.$inferSelect): User { + // Tombstoned (deleted) users — return anonymized profile + if (row.isDeleted === 1) { + return { + id: row.id, + username: 'Deleted User', + displayName: null, + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + status: 'offline', + customStatus: null, + isAdmin: false, + isDeleted: true, + createdAt: row.createdAt, + homeInstance: null, + homeUserId: null, + replicatedInstances: [], + }; + } + let replicatedInstances: ReplicatedInstance[] = []; if (row.replicatedInstances) { try { diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 2a6e0dbb..70a74ac3 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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 { 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 diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e1208e9a..29a7a080 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -15,6 +15,7 @@ export interface User { status: UserStatus; customStatus: string | null; isAdmin: boolean; + isDeleted?: boolean; createdAt: number; homeInstance: string | null; homeUserId: string | null; @@ -482,3 +483,17 @@ export interface VerifyPasswordRequest { export interface VerifyPasswordResponse { valid: boolean; } + +export interface ChangePasswordRequest { + currentPassword?: string; // Required on home, optional for federated users + newPassword: string; +} + +export interface ChangePasswordResponse { + token: string; +} + +export interface DeleteAccountRequest { + password: string; + username: string; // Must match — confirmation safeguard +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index ed4a3c39..91792392 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -29,15 +29,28 @@ import type { InstanceAdminSettings, InstanceInfoResponse, VerifyPasswordResponse, + ChangePasswordRequest, + ChangePasswordResponse, + DeleteAccountRequest, ExploreSpace, JoinRequest, Role, } from '@backspace/shared'; +export class RateLimitError extends Error { + readonly retryAfter: number; + constructor(retryAfter: number) { + super('Rate limit exceeded'); + this.name = 'RateLimitError'; + this.retryAfter = retryAfter; + } +} + export class BackspaceApiClient { readonly auth: { register: (data: RegisterRequest) => Promise; login: (data: LoginRequest) => Promise; + checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; }; readonly users: { @@ -45,6 +58,8 @@ export class BackspaceApiClient { update: (data: UpdateUserRequest) => Promise; get: (id: string) => Promise; verifyPassword: (password: string) => Promise; + changePassword: (data: ChangePasswordRequest) => Promise; + deleteAccount: (data: DeleteAccountRequest) => Promise<{ success: boolean }>; getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>; }; @@ -63,6 +78,7 @@ export class BackspaceApiClient { getBans: (spaceId: string) => Promise<{ spaceId: string; userId: string; reason: string | null; bannedBy: string; createdAt: number; user: any; moderator: any }[]>; ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>; unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>; + transferOwnership: (spaceId: string, newOwnerId: string) => Promise; }; readonly channels: { @@ -174,6 +190,12 @@ export class BackspaceApiClient { }); if (!response.ok) { + if (response.status === 429) { + const body = await response.json().catch(() => ({})); + const retryAfter = (body as { retryAfter?: number }).retryAfter + ?? (parseInt(response.headers.get('retry-after') || '', 10) || 60); + throw new RateLimitError(retryAfter); + } const error = await response.json().catch(() => ({ error: 'Request failed' })); throw new Error((error as { error: string }).error || `HTTP ${response.status}`); } @@ -198,6 +220,12 @@ export class BackspaceApiClient { }); if (!response.ok) { + if (response.status === 429) { + const body = await response.json().catch(() => ({})); + const retryAfter = (body as { retryAfter?: number }).retryAfter + ?? (parseInt(response.headers.get('retry-after') || '', 10) || 60); + throw new RateLimitError(retryAfter); + } const error = await response.json().catch(() => ({ error: 'Upload failed' })); throw new Error((error as { error: string }).error || `HTTP ${response.status}`); } @@ -210,6 +238,8 @@ export class BackspaceApiClient { request('POST', '/auth/register', data, false), login: (data: LoginRequest) => request('POST', '/auth/login', data, false), + checkUsername: (username: string) => + request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), }; this.users = { @@ -218,6 +248,10 @@ export class BackspaceApiClient { get: (id: string) => request('GET', `/users/${id}`), verifyPassword: (password: string) => request('POST', '/users/@me/verify-password', { password }), + changePassword: (data: ChangePasswordRequest) => + request('POST', '/users/@me/change-password', data), + deleteAccount: (data: DeleteAccountRequest) => + request<{ success: boolean }>('DELETE', '/users/@me', data), getMutuals: (id: string, homeUserId?: string) => { const params = new URLSearchParams(); if (homeUserId) params.set('homeUserId', homeUserId); @@ -248,6 +282,8 @@ export class BackspaceApiClient { request<{ success: boolean }>('POST', `/spaces/${spaceId}/bans`, { userId, reason }), unban: (spaceId: string, userId: string) => request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`), + transferOwnership: (spaceId: string, newOwnerId: string) => + request('PATCH', `/spaces/${spaceId}/transfer-ownership`, { newOwnerId }), }; this.channels = { diff --git a/packages/web/src/components/auth/LoginPage.tsx b/packages/web/src/components/auth/LoginPage.tsx index c7b400d2..c74c3edd 100644 --- a/packages/web/src/components/auth/LoginPage.tsx +++ b/packages/web/src/components/auth/LoginPage.tsx @@ -1,15 +1,31 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../stores/authStore'; +import { RateLimitError } from '../../api/client'; export function LoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [retryAfter, setRetryAfter] = useState(0); const login = useAuthStore((s) => s.login); const isLoading = useAuthStore((s) => s.isLoading); const navigate = useNavigate(); + useEffect(() => { + if (retryAfter <= 0) return; + const timer = setInterval(() => { + setRetryAfter((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [retryAfter]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -27,10 +43,17 @@ export function LoginPage() { await login(username.trim(), password); navigate('/channels/@me'); } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed'); + if (err instanceof RateLimitError) { + setRetryAfter(err.retryAfter); + setError(''); + } else { + setError(err instanceof Error ? err.message : 'Login failed'); + } } }; + const isDisabled = isLoading || retryAfter > 0; + return (
@@ -41,6 +64,13 @@ export function LoginPage() {
+ {retryAfter > 0 && ( +
+

Too many login attempts

+

Try again in {retryAfter}s

+
+ )} + {error && (
{error} @@ -76,10 +106,14 @@ export function LoginPage() {

diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index a205dc61..77a0cb34 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -6,7 +6,9 @@ import { ImageCropModal } from '../ui/ImageCropModal'; import { AVATAR_GRADIENT_MAP } from '../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; import type { AvatarColor } from '@backspace/shared'; -import { api } from '../../api/client'; +import { api, RateLimitError } from '../../api/client'; + +type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid'; export function RegisterPage() { // Step state @@ -18,6 +20,12 @@ export function RegisterPage() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + // Username availability check + const [usernameStatus, setUsernameStatus] = useState('idle'); + const [usernameStatusMessage, setUsernameStatusMessage] = useState(''); + const usernameCheckTimerRef = useRef | null>(null); + const usernameCheckAbortRef = useRef(null); + // Step 2 fields const [displayName, setDisplayName] = useState(''); const [avatarColor, setAvatarColor] = useState( @@ -30,6 +38,7 @@ export function RegisterPage() { const [error, setError] = useState(''); const [isRegistering, setIsRegistering] = useState(false); + const [retryAfter, setRetryAfter] = useState(0); const register = useAuthStore((s) => s.register); const updateProfile = useAuthStore((s) => s.updateProfile); @@ -42,6 +51,82 @@ export function RegisterPage() { }; }, [avatarPreview]); + // Debounced username availability check + useEffect(() => { + // Clear previous timer and abort + if (usernameCheckTimerRef.current) clearTimeout(usernameCheckTimerRef.current); + if (usernameCheckAbortRef.current) usernameCheckAbortRef.current.abort(); + + const trimmed = username.trim(); + + if (trimmed.length === 0) { + setUsernameStatus('idle'); + setUsernameStatusMessage(''); + return; + } + + if (trimmed.length < 3 || trimmed.length > 32) { + setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle'); + setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : ''); + return; + } + + if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { + setUsernameStatus('invalid'); + setUsernameStatusMessage('Username can only contain letters, numbers, and underscores'); + return; + } + + setUsernameStatus('checking'); + setUsernameStatusMessage('Checking availability...'); + + usernameCheckTimerRef.current = setTimeout(async () => { + const controller = new AbortController(); + usernameCheckAbortRef.current = controller; + + try { + const result = await api.auth.checkUsername(trimmed); + if (controller.signal.aborted) return; + + if (result.reason) { + setUsernameStatus('invalid'); + setUsernameStatusMessage(result.reason); + } else if (result.available) { + setUsernameStatus('available'); + setUsernameStatusMessage('Username is available'); + } else { + setUsernameStatus('taken'); + setUsernameStatusMessage('Username is already taken'); + } + } catch { + if (controller.signal.aborted) return; + // Network error or rate limit — fall back to idle silently + setUsernameStatus('idle'); + setUsernameStatusMessage(''); + } + }, 500); + + return () => { + if (usernameCheckTimerRef.current) clearTimeout(usernameCheckTimerRef.current); + if (usernameCheckAbortRef.current) usernameCheckAbortRef.current.abort(); + }; + }, [username]); + + // Countdown timer + useEffect(() => { + if (retryAfter <= 0) return; + const timer = setInterval(() => { + setRetryAfter((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [retryAfter]); + // ── Step 1 validation ── const handleContinue = (e: React.FormEvent) => { e.preventDefault(); @@ -60,6 +145,9 @@ export function RegisterPage() { setError('Username can only contain letters, numbers, and underscores'); return; } + if (usernameStatus === 'taken' || usernameStatus === 'invalid') { + return; + } if (!password) { setError('Password is required'); return; @@ -116,7 +204,12 @@ export function RegisterPage() { navigate('/channels/@me'); } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed'); + if (err instanceof RateLimitError) { + setRetryAfter(err.retryAfter); + setError(''); + } else { + setError(err instanceof Error ? err.message : 'Registration failed'); + } setIsRegistering(false); } }; @@ -125,6 +218,8 @@ export function RegisterPage() { const initial = effectiveDisplayName.charAt(0).toUpperCase(); const gradient = AVATAR_GRADIENT_MAP[avatarColor]; + const isDisabled = isRegistering || retryAfter > 0; + return (

@@ -160,6 +255,31 @@ export function RegisterPage() { autoFocus autoComplete="username" /> + {usernameStatus !== 'idle' && ( +
+ {usernameStatus === 'checking' && ( + + + + + )} + {usernameStatus === 'available' && ( + + + + )} + {(usernameStatus === 'taken' || usernameStatus === 'invalid') && ( + + + + )} + {usernameStatusMessage} +
+ )}
@@ -190,7 +310,8 @@ export function RegisterPage() { @@ -210,6 +331,13 @@ export function RegisterPage() {

Personalize your profile, or skip for now

+ {retryAfter > 0 && ( +
+

Too many attempts

+

Try again in {retryAfter}s

+
+ )} + {error && (
{error} @@ -297,16 +425,20 @@ export function RegisterPage() {
diff --git a/packages/web/src/components/modals/DeleteAccountModal.tsx b/packages/web/src/components/modals/DeleteAccountModal.tsx new file mode 100644 index 00000000..4b451aa3 --- /dev/null +++ b/packages/web/src/components/modals/DeleteAccountModal.tsx @@ -0,0 +1,398 @@ +import { useState, useEffect } from 'react'; +import { useAuthStore } from '../../stores/authStore'; +import { useInstanceStore } from '../../stores/instanceStore'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { api } from '../../api/client'; +import { deleteAccountOnRemotes, type FederationOpResult } from '../../utils/federationOps'; + +interface DeleteAccountModalProps { + isOpen: boolean; + onClose: () => void; +} + +type Step = 'warning' | 'confirm' | 'federation' | 'complete'; + +interface OwnedSpaceInfo { + id: string; + name: string; + members: { userId: string; username: string; displayName: string | null }[]; + action: 'none' | 'transfer' | 'delete'; + transferTo: string; +} + +export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps) { + const user = useAuthStore((s) => s.user); + const instances = useInstanceStore((s) => s.instances); + const spaces = useSpaceStore((s) => s.spaces); + + const [step, setStep] = useState('warning'); + const [ownedSpaces, setOwnedSpaces] = useState([]); + const [confirmUsername, setConfirmUsername] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [federationResults, setFederationResults] = useState([]); + const [deletionComplete, setDeletionComplete] = useState(false); + + // Reset state when modal opens/closes + useEffect(() => { + if (isOpen) { + setStep('warning'); + setConfirmUsername(''); + setConfirmPassword(''); + setError(''); + setIsLoading(false); + setFederationResults([]); + setDeletionComplete(false); + + // Build owned spaces list and fetch members for each + if (user) { + const owned = spaces.filter(s => s.ownerId === user.id); + if (owned.length > 0) { + Promise.all( + owned.map(async (s) => { + try { + const members = await api.spaces.members(s.id); + return { + id: s.id, + name: s.name, + members: members + .filter(m => m.userId !== user.id) + .map(m => ({ + userId: m.userId, + username: m.user.username, + displayName: m.user.displayName, + })), + action: 'none' as const, + transferTo: '', + }; + } catch { + return { + id: s.id, + name: s.name, + members: [] as OwnedSpaceInfo['members'], + action: 'none' as const, + transferTo: '', + }; + } + }) + ).then(setOwnedSpaces); + } + } + } + }, [isOpen, user]); + + // Auto-redirect after deletion — must be before early return to maintain hooks order + useEffect(() => { + if (deletionComplete && step === 'complete') { + const timer = setTimeout(() => { + localStorage.removeItem('backspace_token'); + window.location.href = '/login'; + }, 3000); + return () => clearTimeout(timer); + } + }, [deletionComplete, step]); + + if (!isOpen || !user) return null; + + const hasRemotes = instances.filter(i => i.status === 'connected').length > 0; + const allOwnedHandled = ownedSpaces.every(s => s.action !== 'none'); + + const handleSpaceAction = (spaceId: string, action: 'transfer' | 'delete') => { + setOwnedSpaces(prev => prev.map(s => + s.id === spaceId ? { ...s, action, transferTo: action === 'transfer' ? s.transferTo : '' } : s + )); + }; + + const handleTransferTo = (spaceId: string, userId: string) => { + setOwnedSpaces(prev => prev.map(s => + s.id === spaceId ? { ...s, transferTo: userId } : s + )); + }; + + const handleContinueFromWarning = async () => { + setError(''); + setIsLoading(true); + + try { + // Process owned spaces + for (const space of ownedSpaces) { + if (space.action === 'transfer' && space.transferTo) { + await api.spaces.transferOwnership(space.id, space.transferTo); + } else if (space.action === 'delete') { + await api.spaces.delete(space.id); + } + } + setStep('confirm'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to process spaces'); + } finally { + setIsLoading(false); + } + }; + + const handleConfirmDelete = async () => { + if (confirmUsername !== user.username) { + setError('Username does not match'); + return; + } + + setError(''); + setIsLoading(true); + + try { + if (hasRemotes) { + setStep('federation'); + // Delete on remotes first + const results = await deleteAccountOnRemotes(); + setFederationResults(results); + // Then delete home account + await api.users.deleteAccount({ password: confirmPassword, username: confirmUsername }); + setDeletionComplete(true); + setStep('complete'); + } else { + // No remotes — direct delete via API (don't clear auth state yet — let the modal show "complete") + await api.users.deleteAccount({ password: confirmPassword, username: confirmUsername }); + setDeletionComplete(true); + setStep('complete'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete account'); + if (step === 'federation') { + // Stay on federation step so user can see results + } else { + setStep('confirm'); + } + } finally { + setIsLoading(false); + } + }; + + const handleDeleteAnyway = async () => { + setError(''); + setIsLoading(true); + try { + await api.users.deleteAccount({ password: confirmPassword, username: confirmUsername }); + setDeletionComplete(true); + setStep('complete'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete account'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ {/* Header */} +
+

Delete Account

+ {step !== 'complete' && ( + + )} +
+ +
+ {/* Step 1: Warning & Space Handling */} + {step === 'warning' && ( + <> +
+

This will permanently delete your account.

+
    +
  • - All space memberships will be removed
  • +
  • - All friend connections will be removed
  • +
  • - All DM memberships will be removed
  • +
  • - Your messages will remain but be attributed to "Deleted User"
  • +
+
+ + {ownedSpaces.length > 0 && ( +
+

+ You own {ownedSpaces.length} space{ownedSpaces.length > 1 ? 's' : ''}. Handle each before continuing: +

+
+ {ownedSpaces.map(space => ( +
+
{space.name}
+
+ + +
+ {space.action === 'transfer' && ( + + )} +
+ ))} +
+
+ )} + + {error && ( +
{error}
+ )} + + + + )} + + {/* Step 2: Confirmation */} + {step === 'confirm' && ( + <> +
+

This action is permanent and cannot be undone.

+
+ +
+ + setConfirmUsername(e.target.value)} + className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-rose" + placeholder={user.username} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-rose" + placeholder="Enter your password" + /> +
+ + {error && ( +
{error}
+ )} + +
+ + +
+ + )} + + {/* Step 3: Federation Progress */} + {step === 'federation' && ( + <> +

Removing your account from connected instances...

+
+ {instances.filter(i => i.status === 'connected').map(inst => { + const result = federationResults.find(r => r.origin === inst.origin); + return ( +
+ {inst.label || new URL(inst.origin).host} + {!result ? ( + + + + + ) : result.success ? ( + + + + ) : ( +
+ + + + {result.error} +
+ )} +
+ ); + })} +
+ + {error && ( +
{error}
+ )} + + {federationResults.length > 0 && !deletionComplete && ( + + )} + + )} + + {/* Step 4: Complete */} + {step === 'complete' && ( +
+ + + +

Account deleted

+

Redirecting to login...

+
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 040beefe..3b801249 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -1,11 +1,14 @@ import { useState, useEffect, useRef } from 'react'; import { useAuthStore } from '../../../stores/authStore'; +import { useInstanceStore } from '../../../stores/instanceStore'; import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; +import { DeleteAccountModal } from '../DeleteAccountModal'; import { api } from '../../../api/client'; import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; +import type { FederationOpResult } from '../../../utils/federationOps'; export function AccountPanel() { @@ -57,6 +60,23 @@ export function AccountPanel() { } }, [user?.displayName, user?.customStatus, user?.status, user?.bio, user?.accentColor, user?.avatarColor, user?.avatar, user?.banner]); + // Password change state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmNewPassword, setConfirmNewPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordSuccess, setPasswordSuccess] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordResults, setPasswordResults] = useState(null); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + + // Delete account state + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const instances = useInstanceStore((s) => s.instances); + const changePassword = useAuthStore((s) => s.changePassword); + if (!user) return null; const effectiveDisplayName = displayName.trim() || user.username; @@ -189,6 +209,43 @@ export function AccountPanel() { } }; + const handleChangePassword = async () => { + setPasswordError(''); + setPasswordSuccess(''); + setPasswordResults(null); + + if (newPassword.length < 6) { + setPasswordError('New password must be at least 6 characters'); + return; + } + if (newPassword !== confirmNewPassword) { + setPasswordError('Passwords do not match'); + return; + } + + setPasswordLoading(true); + try { + const results = await changePassword(currentPassword, newPassword); + setPasswordSuccess('Password changed successfully!'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmNewPassword(''); + + if (results.length > 0) { + setPasswordResults(results); + } + + setTimeout(() => { + setPasswordSuccess(''); + setPasswordResults(null); + }, 5000); + } catch (err) { + setPasswordError(err instanceof Error ? err.message : 'Failed to change password'); + } finally { + setPasswordLoading(false); + } + }; + const handleReset = () => { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); @@ -524,6 +581,124 @@ export function AccountPanel() {
+ {/* ── Password ── */} +
+
Password
+
+
+ +
+ setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + placeholder="Enter current password" + /> + +
+
+
+ +
+ setNewPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + placeholder="Minimum 6 characters" + /> + +
+
+
+ + setConfirmNewPassword(e.target.value)} + className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + placeholder="Confirm new password" + /> +
+ + {passwordError && ( +
{passwordError}
+ )} + {passwordSuccess && ( +
{passwordSuccess}
+ )} + {passwordResults && passwordResults.length > 0 && ( +
+ {passwordResults.map(r => ( +
+ {r.origin} + {r.success ? ( + Synced + ) : ( + Failed — will sync on reconnect + )} +
+ ))} +
+ )} + + +
+
+ + {/* ── Danger Zone ── */} +
+
Danger Zone
+
+

+ Once you delete your account, there is no going back. Your messages will remain but be attributed to "Deleted User". +

+ +
+
+ {error && (
{error}
)} @@ -572,6 +747,11 @@ export function AccountPanel() { cropShape="rect" aspectRatio={3} /> + + setShowDeleteModal(false)} + />
); } diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts index 9cf12747..81b30561 100644 --- a/packages/web/src/stores/authStore.ts +++ b/packages/web/src/stores/authStore.ts @@ -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; updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; + deleteAccount: (password: string, username: string) => Promise; setUser: (user: User) => void; clearError: () => void; } @@ -103,6 +106,32 @@ export const useAuthStore = create((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 }), diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index cdb104a5..5965d540 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -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 { } } -function saveCachedTokens(instances: ConnectedInstance[], userId: string): void { +function saveCachedTokens(instances: ConnectedInstance[], userId: string, pendingSyncFlags?: Record): void { const cache: Record = {}; + // 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; reauthenticateInstance: (origin: string, password: string) => Promise; + updateInstanceToken: (origin: string, newToken: string) => void; + setPendingPasswordSync: (origin: string, pending: boolean) => void; + hasPendingPasswordSync: (origin: string) => boolean; syncInstanceList: () => Promise; autoConnectAll: () => Promise; reset: () => void; @@ -423,6 +430,42 @@ export const useInstanceStore = create((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 = { [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 () => { diff --git a/packages/web/src/utils/federationOps.ts b/packages/web/src/utils/federationOps.ts new file mode 100644 index 00000000..bf0bf147 --- /dev/null +++ b/packages/web/src/utils/federationOps.ts @@ -0,0 +1,104 @@ +import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface FederationOpResult { + origin: string; + success: boolean; + error?: string; +} + +// ─── Retry helper ──────────────────────────────────────────────────────── + +async function retryWithBackoff( + fn: () => Promise, + maxAttempts = 3, + baseDelay = 2000, +): Promise { + let lastError: unknown; + for (let i = 0; i < maxAttempts; i++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (i < maxAttempts - 1) { + await new Promise(resolve => setTimeout(resolve, baseDelay * Math.pow(2, i))); + } + } + } + throw lastError; +} + +// ─── Password change propagation ──────────────────────────────────────── + +/** + * Change password on all connected remote instances. + * For federated users, only newPassword is needed (JWT auth is sufficient). + */ +export async function changePasswordOnRemotes(newPassword: string): Promise { + const { instances } = useInstanceStore.getState(); + const connected = instances.filter(i => i.status === 'connected'); + + if (connected.length === 0) return []; + + const results = await Promise.allSettled( + connected.map(async (inst): Promise => { + try { + const response = await retryWithBackoff( + () => inst.api.users.changePassword({ newPassword }), + 3, + 2000, + ); + + // Update the cached token for this instance + useInstanceStore.getState().updateInstanceToken(inst.origin, response.token); + + return { origin: inst.origin, success: true }; + } catch (err) { + // Mark as pending sync for later retry + useInstanceStore.getState().setPendingPasswordSync(inst.origin, true); + + return { + origin: inst.origin, + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + }) + ); + + return results.map(r => r.status === 'fulfilled' ? r.value : { origin: '', success: false, error: 'Unexpected error' }); +} + +// ─── Account deletion propagation ─────────────────────────────────────── + +/** + * Delete account on all connected remote instances (best-effort). + * For federated users on remotes, password verification is skipped server-side. + */ +export async function deleteAccountOnRemotes(): Promise { + const { instances } = useInstanceStore.getState(); + const connected = instances.filter(i => i.status === 'connected'); + + if (connected.length === 0) return []; + + const results = await Promise.allSettled( + connected.map(async (inst): Promise => { + try { + await inst.api.users.deleteAccount({ + password: '', // Not needed for federated users + username: inst.username, + }); + return { origin: inst.origin, success: true }; + } catch (err) { + return { + origin: inst.origin, + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + }) + ); + + return results.map(r => r.status === 'fulfilled' ? r.value : { origin: '', success: false, error: 'Unexpected error' }); +}