feat: standardize input styling with tier system, add depth and admin features
- Define 4 input tier CSS classes (input-standard, input-search, input-embedded, input-danger) in globals.css, migrating ~50 inputs across ~28 component files to use them - Add subtle border and inset shadow to solid input tiers for resting-state visibility - Fix focus ring clipping in settings panel scroll container - Fix phantom Tailwind tokens (border-border-primary, placeholder-txt-muted) - Add admin user management panel, storage management, and account deletion utilities
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, or, and, inArray } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
import { deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -125,123 +125,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const uid = request.userId;
|
||||
|
||||
// Collect file references before the transaction (avatar, banner)
|
||||
const filesToDelete: string[] = [];
|
||||
if (user.avatar) filesToDelete.push(user.avatar);
|
||||
if (user.banner) filesToDelete.push(user.banner);
|
||||
|
||||
// Find group DMs this user owns so we can transfer ownership
|
||||
const ownedGroupDms = db.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.ownerId, uid))
|
||||
.all();
|
||||
|
||||
// Run all cleanup in a single transaction
|
||||
db.transaction((tx) => {
|
||||
// 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 */ }
|
||||
|
||||
// Nullify moderator references pointing to this user
|
||||
try {
|
||||
tx.update(schema.bans).set({ bannedBy: null }).where(eq(schema.bans.bannedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.voiceRestrictions).set({ moderatorId: null }).where(eq(schema.voiceRestrictions.moderatorId, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.joinRequests).set({ decidedBy: null }).where(eq(schema.joinRequests.decidedBy, 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();
|
||||
|
||||
// Transfer ownership of group DMs to the next remaining member
|
||||
for (const { id: dmId } of ownedGroupDms) {
|
||||
const nextMember = tx.select({ userId: schema.dmMembers.userId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmId))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (nextMember) {
|
||||
tx.update(schema.dmChannels)
|
||||
.set({ ownerId: nextMember.userId })
|
||||
.where(eq(schema.dmChannels.id, dmId))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned DM channels (zero members after our removal)
|
||||
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.all()
|
||||
.filter(dc => {
|
||||
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dc.id))
|
||||
.all()
|
||||
.length;
|
||||
return memberCount === 0;
|
||||
})
|
||||
.map(dc => dc.id);
|
||||
|
||||
for (const dmId of orphanedDmIds) {
|
||||
// Collect message IDs for this orphaned DM channel
|
||||
const msgIds = tx.select({ id: schema.dmMessages.id })
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, dmId))
|
||||
.all()
|
||||
.map(m => m.id);
|
||||
|
||||
if (msgIds.length > 0) {
|
||||
// Collect attachment filenames for cleanup after tx
|
||||
const dmAttachments = tx.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
for (const att of dmAttachments) filesToDelete.push(att.filename);
|
||||
|
||||
// Delete attachments + reactions for all messages in this DM channel
|
||||
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||
}
|
||||
// Delete the DM channel (cascades to dm_messages)
|
||||
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).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();
|
||||
});
|
||||
// Tombstone the account (transaction handles all DB cleanup)
|
||||
const filesToDelete = tombstoneUser(request.userId);
|
||||
|
||||
// Clean up files from disk after transaction commits
|
||||
for (const filename of filesToDelete) {
|
||||
@@ -569,6 +454,16 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
.set({ name: folder.name, color: folder.color })
|
||||
.where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
// Folder from a remote instance — create it locally with the original ID
|
||||
tx.insert(schema.spaceFolders).values({
|
||||
id: key,
|
||||
userId,
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
position: 0,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
// Clear and re-insert folder members with position
|
||||
|
||||
Reference in New Issue
Block a user