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
+45 -3
View File
@@ -13,7 +13,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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<void> {
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 });