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
+246
View File
@@ -0,0 +1,246 @@
import type { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { eq, like, or, and, ne, sql } from 'drizzle-orm';
import { authenticate, requireAdmin, hashPassword } from '../utils/auth.js';
import { getStorageStats, getOrphanedFiles, cleanupStorage } from '../utils/storageJanitor.js';
import { getDb, schema } from '../db/index.js';
import { connectionManager } from '../ws/handler.js';
import { tombstoneUser } from '../utils/userDeletion.js';
import { deleteUploadFile } from '../utils/fileCleanup.js';
import { sanitizeUser } from '../utils/sanitize.js';
import type { AdminUser, AdminUserListResponse, AdminResetPasswordResponse } from '@backspace/shared';
function toAdminUser(row: typeof schema.users.$inferSelect): AdminUser {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
avatarColor: row.avatarColor,
status: row.status ?? 'offline',
isAdmin: row.isAdmin === 1,
isDeleted: row.isDeleted === 1,
homeInstance: row.homeInstance,
createdAt: row.createdAt,
};
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
// ─── Storage Management ──────────────────────────────────────────────────
// GET /api/admin/storage/stats — storage overview
app.get('/api/admin/storage/stats', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
try {
const stats = getStorageStats();
return reply.code(200).send(stats);
} catch (err: any) {
return reply.code(500).send({ error: `Failed to compute storage stats: ${err.message}`, statusCode: 500 });
}
});
// GET /api/admin/storage/orphans — list orphaned files
app.get('/api/admin/storage/orphans', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
try {
const orphans = getOrphanedFiles();
return reply.code(200).send({ orphans });
} catch (err: any) {
return reply.code(500).send({ error: `Failed to list orphaned files: ${err.message}`, statusCode: 500 });
}
});
// POST /api/admin/storage/cleanup — delete orphaned files
app.post<{ Body: { dryRun?: boolean } }>('/api/admin/storage/cleanup', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
try {
const dryRun = request.body?.dryRun ?? false;
const result = cleanupStorage(dryRun);
return reply.code(200).send(result);
} catch (err: any) {
return reply.code(500).send({ error: `Cleanup failed: ${err.message}`, statusCode: 500 });
}
});
// ─── User Management ────────────────────────────────────────────────────
// GET /api/admin/users — paginated user list with search
app.get<{ Querystring: { q?: string; page?: string; pageSize?: string; showDeleted?: string } }>(
'/api/admin/users',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const db = getDb();
const q = request.query.q?.trim() || '';
const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(request.query.pageSize || '50', 10) || 50));
const showDeleted = request.query.showDeleted === 'true';
const conditions = [];
if (!showDeleted) {
conditions.push(eq(schema.users.isDeleted, 0));
}
if (q) {
const pattern = `%${q}%`;
conditions.push(or(
like(schema.users.username, pattern),
like(schema.users.displayName, pattern),
)!);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const countResult = db.select({ count: sql<number>`count(*)` })
.from(schema.users)
.where(where)
.get();
const total = countResult?.count ?? 0;
const rows = db.select()
.from(schema.users)
.where(where)
.orderBy(sql`${schema.users.createdAt} DESC`)
.limit(pageSize)
.offset((page - 1) * pageSize)
.all();
const response: AdminUserListResponse = {
users: rows.map(toAdminUser),
total,
page,
pageSize,
};
return reply.code(200).send(response);
},
);
// PATCH /api/admin/users/:id/role — promote/demote admin
app.patch<{ Params: { id: string }; Body: { isAdmin: boolean } }>(
'/api/admin/users/:id/role',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const { id: targetId } = request.params;
const { isAdmin } = request.body;
if (typeof isAdmin !== 'boolean') {
return reply.code(400).send({ error: 'isAdmin must be a boolean', statusCode: 400 });
}
const db = getDb();
const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
if (!target) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (target.isDeleted === 1) {
return reply.code(400).send({ error: 'Cannot change role of a deleted user', statusCode: 400 });
}
// Promote: block federated users
if (isAdmin && target.homeInstance) {
return reply.code(403).send({ error: 'Federated users cannot be promoted to admin', statusCode: 403 });
}
// Demote: prevent removing the last admin
if (!isAdmin && target.isAdmin === 1) {
const adminCount = db.select({ count: sql<number>`count(*)` })
.from(schema.users)
.where(and(eq(schema.users.isAdmin, 1), eq(schema.users.isDeleted, 0)))
.get();
if ((adminCount?.count ?? 0) <= 1) {
return reply.code(400).send({ error: 'Cannot demote the last admin', statusCode: 400 });
}
}
db.update(schema.users)
.set({ isAdmin: isAdmin ? 1 : 0 })
.where(eq(schema.users.id, targetId))
.run();
const updated = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get()!;
// Broadcast user_updated so the target's UI reflects the isAdmin change
connectionManager.sendToUser(targetId, {
type: 'user_updated',
user: sanitizeUser(updated),
});
return reply.code(200).send(toAdminUser(updated));
},
);
// POST /api/admin/users/:id/reset-password — generate temporary password
app.post<{ Params: { id: string } }>(
'/api/admin/users/:id/reset-password',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const { id: targetId } = request.params;
const db = getDb();
const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
if (!target) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (target.isDeleted === 1) {
return reply.code(400).send({ error: 'Cannot reset password of a deleted user', statusCode: 400 });
}
if (target.homeInstance) {
return reply.code(400).send({ error: 'Federated users authenticate via their home instance', statusCode: 400 });
}
const temporaryPassword = crypto.randomBytes(12).toString('base64url');
const hash = await hashPassword(temporaryPassword);
db.update(schema.users)
.set({ passwordHash: hash })
.where(eq(schema.users.id, targetId))
.run();
// Force re-auth by disconnecting all sessions
connectionManager.forceDisconnectUser(targetId);
const response: AdminResetPasswordResponse = { temporaryPassword };
return reply.code(200).send(response);
},
);
// DELETE /api/admin/users/:id — tombstone a user account
app.delete<{ Params: { id: string } }>(
'/api/admin/users/:id',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const { id: targetId } = request.params;
const db = getDb();
if (targetId === request.userId) {
return reply.code(400).send({ error: 'Use account settings to delete your own account', statusCode: 400 });
}
const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
if (!target) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (target.isDeleted === 1) {
return reply.code(400).send({ error: 'User is already deleted', statusCode: 400 });
}
// 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, targetId))
.all();
if (ownedSpaces.length > 0) {
return reply.code(400).send({
error: 'User owns spaces — transfer ownership first',
statusCode: 400,
ownedSpaces,
});
}
const filesToDelete = tombstoneUser(targetId);
for (const filename of filesToDelete) {
deleteUploadFile(filename);
}
connectionManager.forceDisconnectUser(targetId);
return reply.code(200).send({ success: true });
},
);
}
+4 -20
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { authenticate, requireAdmin } from '../utils/auth.js';
import { config } from '../config.js';
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
@@ -33,15 +33,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
});
// PATCH /api/settings/streaming — admin only
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => {
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
const db = getDb();
// Verify caller is an instance admin
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!caller || caller.isAdmin !== 1) {
return reply.code(403).send({ error: 'Only instance admins can modify streaming settings', statusCode: 403 });
}
const body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
@@ -129,14 +123,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
});
// GET /api/settings/instance — admin only, returns instance admin settings
app.get('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
app.get('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
const db = getDb();
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!caller || caller.isAdmin !== 1) {
return reply.code(403).send({ error: 'Only instance admins can view instance settings', statusCode: 403 });
}
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!row) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
@@ -152,14 +141,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
});
// PATCH /api/settings/instance — admin only, updates instance admin settings
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
const db = getDb();
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!caller || caller.isAdmin !== 1) {
return reply.code(403).send({ error: 'Only instance admins can modify instance settings', statusCode: 403 });
}
const body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
+14 -119
View File
@@ -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