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:
Jannis Braun
2026-03-14 13:49:32 +01:00
parent afbc4b5e31
commit 836f0acef6
45 changed files with 1478 additions and 235 deletions
+132
View File
@@ -0,0 +1,132 @@
import crypto from 'crypto';
import { eq, or, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
/**
* Tombstone a user account: removes them from all spaces, DMs, friends,
* roles, reactions, folders, bans, voice restrictions, channel overrides,
* transfers group DM ownership, cleans up orphaned DMs, and marks the
* user row as deleted.
*
* Returns a list of filenames to delete from disk (avatar, banner,
* orphaned DM attachments). The caller is responsible for disk cleanup
* and WebSocket disconnection after calling this.
*/
export function tombstoneUser(uid: string): string[] {
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
if (!user) return [];
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();
db.transaction((tx) => {
// Remove from spaces, roles, friends, DMs, read states, reactions, folders
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) {
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) {
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);
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
}
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'),
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();
});
return filesToDelete;
}