From 836f0acef68f8b53c1b6a48f484931a4b0d53d3e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:49:32 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 29 +- packages/server/src/index.ts | 2 + packages/server/src/routes/admin.ts | 246 +++++++++++++ packages/server/src/routes/settings.ts | 24 +- packages/server/src/routes/users.ts | 133 +------ packages/server/src/utils/auth.ts | 13 + packages/server/src/utils/storageJanitor.ts | 259 +++++++++++++ packages/server/src/utils/userDeletion.ts | 132 +++++++ packages/shared/src/types.ts | 60 ++++ packages/web/src/api/client.ts | 36 ++ packages/web/src/components/JoinPage.tsx | 12 +- .../web/src/components/auth/LoginPage.tsx | 4 +- .../web/src/components/auth/RegisterPage.tsx | 8 +- .../web/src/components/chat/ExplorePage.tsx | 8 +- .../web/src/components/chat/FriendsPage.tsx | 8 +- packages/web/src/components/chat/Message.tsx | 6 +- .../web/src/components/chat/MessageInput.tsx | 2 +- .../web/src/components/chat/SearchPopover.tsx | 10 +- .../web/src/components/layout/DmSearchBar.tsx | 2 +- .../src/components/layout/SpaceSidebar.tsx | 8 +- .../components/modals/AddDmMemberModal.tsx | 2 +- .../modals/ChannelSettingsModal.tsx | 2 +- .../components/modals/ConnectedInstances.tsx | 38 +- .../src/components/modals/CreateCategory.tsx | 2 +- .../src/components/modals/CreateChannel.tsx | 6 +- .../web/src/components/modals/CreateSpace.tsx | 4 +- .../components/modals/DeleteAccountModal.tsx | 13 +- .../web/src/components/modals/InviteModal.tsx | 2 +- .../web/src/components/modals/JoinSpace.tsx | 12 +- .../web/src/components/modals/NewDmModal.tsx | 2 +- .../src/components/modals/SpaceSettings.tsx | 2 +- .../modals/TransferOwnershipModal.tsx | 4 +- .../src/components/modals/UserSettings.tsx | 2 +- .../instanceSettingsPanels/GeneralPanel.tsx | 2 +- .../instanceSettingsPanels/StoragePanel.tsx | 196 ++++++++++ .../instanceSettingsPanels/StreamingPanel.tsx | 2 +- .../instanceSettingsPanels/UsersPanel.tsx | 340 ++++++++++++++++++ .../modals/settingsPanels/AccountPanel.tsx | 26 +- .../modals/settingsPanels/InstancePanel.tsx | 12 +- .../spaceSettingsPanels/OverviewPanel.tsx | 6 +- .../modals/spaceSettingsPanels/RolesPanel.tsx | 4 +- packages/web/src/components/ui/Avatar.tsx | 2 +- .../src/components/ui/UserProfilePopout.tsx | 2 +- packages/web/src/styles/globals.css | 27 ++ packages/web/tailwind.config.js | 1 + 45 files changed, 1478 insertions(+), 235 deletions(-) create mode 100644 packages/server/src/routes/admin.ts create mode 100644 packages/server/src/utils/storageJanitor.ts create mode 100644 packages/server/src/utils/userDeletion.ts create mode 100644 packages/web/src/components/modals/instanceSettingsPanels/StoragePanel.tsx create mode 100644 packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d9f42781..af483638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,19 @@ Every surface in Backspace falls into one of these tiers: **Modal backdrops** use `bg-black/50` — light enough for the glass card's blur to show through. +### Input Tiers + +Every text input, textarea, and select uses one of these CSS classes (defined in `globals.css`): + +| Tier | Class | When to Use | Focus | +|------|-------|-------------|-------| +| Standard | `.input-standard` | Form fields in modals, settings, auth pages | `ring-2` primary | +| Search | `.input-search` | Search bars, filter inputs, compact lookups | `ring-1` primary | +| Embedded | `.input-embedded` | Inside glass containers (chat input, search popover, DM search) | none | +| Danger | `.input-danger` | Destructive confirmations (delete account) | `ring-2` rose | + +**Rule:** No resting border — the sunken `surface-input` background provides differentiation. Override padding/size with utility classes when needed (e.g. `input-standard w-full py-2.5` for taller auth inputs). + ## MISSION Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed across multiple instances with federation support. Every change must uphold the same standard: no stubs, no TODOs, no shortcuts. A user must always be able to `docker compose up` and have a fully working chat platform. @@ -112,6 +125,7 @@ Backspace/ │ │ │ ├── seed.ts │ │ │ └── migrate.ts │ │ ├── routes/ +│ │ │ ├── admin.ts │ │ │ ├── auth.ts │ │ │ ├── users.ts │ │ │ ├── spaces.ts @@ -134,7 +148,8 @@ Backspace/ │ │ ├── snowflake.ts │ │ ├── permissions.ts │ │ ├── sanitize.ts -│ │ └── fileCleanup.ts +│ │ ├── fileCleanup.ts +│ │ └── storageJanitor.ts │ ├── web/ │ │ ├── package.json │ │ ├── tsconfig.json @@ -252,7 +267,8 @@ Backspace/ │ │ │ │ │ └── BansPanel.tsx │ │ │ │ └── instanceSettingsPanels/ │ │ │ │ ├── GeneralPanel.tsx -│ │ │ │ └── StreamingPanel.tsx +│ │ │ │ ├── StreamingPanel.tsx +│ │ │ │ └── StoragePanel.tsx │ │ │ └── ui/ │ │ │ ├── Avatar.tsx │ │ │ ├── Modal.tsx @@ -650,6 +666,15 @@ PATCH /api/settings/streaming (auth, admin) { maxBitrateKbps?, ... } → { GET /api/settings/instance (auth, admin) → { instanceName, registrationOpen, discoveryEnabled } PATCH /api/settings/instance (auth, admin) { instanceName?, registrationOpen?, discoveryEnabled? } → { settings } +# Admin +GET /api/admin/storage/stats (auth, admin) → StorageStats +GET /api/admin/storage/orphans (auth, admin) → { orphans: OrphanedFile[] } +POST /api/admin/storage/cleanup (auth, admin) { dryRun?: boolean } → CleanupResult +GET /api/admin/users (auth, admin) ?q=&page=&pageSize=&showDeleted= → AdminUserListResponse +PATCH /api/admin/users/:id/role (auth, admin) { isAdmin: boolean } → AdminUser +POST /api/admin/users/:id/reset-password (auth, admin) → { temporaryPassword } +DELETE /api/admin/users/:id (auth, admin) → { success } + # Utilities GET /api/utils/metadata (auth) ?url= → { title?, description?, image?, siteName? } GET /api/health (public) → { status: 'ok', timestamp } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b5589d08..0e046816 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -22,6 +22,7 @@ import { utilRoutes } from './routes/utils.js'; import { instanceRoutes } from './routes/instance.js'; import { exploreRoutes } from './routes/explore.js'; import { searchRoutes } from './routes/search.js'; +import { adminRoutes } from './routes/admin.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; import fs from 'fs'; @@ -88,6 +89,7 @@ async function main(): Promise { await app.register(instanceRoutes); await app.register(exploreRoutes); await app.register(searchRoutes); + await app.register(adminRoutes); await app.register(registerWebSocket); app.get('/api/health', async () => { diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts new file mode 100644 index 00000000..169afcba --- /dev/null +++ b/packages/server/src/routes/admin.ts @@ -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 { + // ─── 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`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`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 }); + }, + ); +} diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 3a6d7ed5..46e96be4 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -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 { }); // PATCH /api/settings/streaming — admin only - app.patch<{ Body: Partial }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => { + app.patch<{ Body: Partial }>('/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 = { updatedAt: Date.now() }; @@ -129,14 +123,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise { }); // 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 { }); // PATCH /api/settings/instance — admin only, updates instance admin settings - app.patch<{ Body: Partial }>('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => { + app.patch<{ Body: Partial }>('/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 = { updatedAt: Date.now() }; diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index e9d3040f..43010aa8 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -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 { @@ -125,123 +125,8 @@ export async function userRoutes(app: FastifyInstance): Promise { }); } - 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 { .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 diff --git a/packages/server/src/utils/auth.ts b/packages/server/src/utils/auth.ts index c946972d..dbe4be4e 100644 --- a/packages/server/src/utils/auth.ts +++ b/packages/server/src/utils/auth.ts @@ -1,6 +1,8 @@ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import { eq } from 'drizzle-orm'; import { config } from '../config.js'; +import { getDb, schema } from '../db/index.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; const SALT_ROUNDS = 12; @@ -50,6 +52,17 @@ export async function authenticate( } } +export async function requireAdmin( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const db = getDb(); + const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!caller || caller.isAdmin !== 1) { + reply.code(403).send({ error: 'Only instance admins can perform this action', statusCode: 403 }); + } +} + declare module 'fastify' { interface FastifyRequest { userId: string; diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts new file mode 100644 index 00000000..e660c10b --- /dev/null +++ b/packages/server/src/utils/storageJanitor.ts @@ -0,0 +1,259 @@ +import fs from 'fs'; +import path from 'path'; +import { eq, isNotNull } from 'drizzle-orm'; +import { config } from '../config.js'; +import { getDb, schema } from '../db/index.js'; +import { deleteUploadFile } from './fileCleanup.js'; +import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared'; + +const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']); +const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']); +const AUDIO_EXTS = new Set(['.mp3', '.ogg', '.wav', '.flac', '.aac', '.m4a', '.opus']); +const DOC_EXTS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.json', '.xml']); + +/** 1-hour threshold: attachments uploaded but never linked to a message */ +const UNLINKED_AGE_MS = 60 * 60 * 1000; + +function classifyFile(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + if (IMAGE_EXTS.has(ext)) return 'image'; + if (VIDEO_EXTS.has(ext)) return 'video'; + if (AUDIO_EXTS.has(ext)) return 'audio'; + if (DOC_EXTS.has(ext)) return 'document'; + return 'other'; +} + +interface DiskFile { + filename: string; + size: number; + modifiedAt: number; +} + +function getDiskFiles(): DiskFile[] { + try { + const entries = fs.readdirSync(config.uploadDir, { withFileTypes: true }); + const files: DiskFile[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + try { + const stat = fs.statSync(path.join(config.uploadDir, entry.name)); + files.push({ + filename: entry.name, + size: stat.size, + modifiedAt: stat.mtimeMs, + }); + } catch { + // Skip files that vanished between readdir and stat + } + } + return files; + } catch (err: any) { + if (err.code === 'ENOENT') return []; + throw err; + } +} + +function getReferencedFilenames(): Set { + const db = getDb(); + const referenced = new Set(); + + // Attachment filenames + const attachmentRows = db.select({ filename: schema.attachments.filename }) + .from(schema.attachments).all(); + for (const row of attachmentRows) { + referenced.add(path.basename(row.filename)); + } + + // Attachment thumbnails + const thumbRows = db.select({ thumbnailFilename: schema.attachments.thumbnailFilename }) + .from(schema.attachments) + .where(isNotNull(schema.attachments.thumbnailFilename)) + .all(); + for (const row of thumbRows) { + if (row.thumbnailFilename) referenced.add(path.basename(row.thumbnailFilename)); + } + + // User avatars + const avatarRows = db.select({ avatar: schema.users.avatar }) + .from(schema.users) + .where(isNotNull(schema.users.avatar)) + .all(); + for (const row of avatarRows) { + if (row.avatar) referenced.add(path.basename(row.avatar)); + } + + // User banners + const bannerRows = db.select({ banner: schema.users.banner }) + .from(schema.users) + .where(isNotNull(schema.users.banner)) + .all(); + for (const row of bannerRows) { + if (row.banner) referenced.add(path.basename(row.banner)); + } + + // Space icons + const iconRows = db.select({ icon: schema.spaces.icon }) + .from(schema.spaces) + .where(isNotNull(schema.spaces.icon)) + .all(); + for (const row of iconRows) { + if (row.icon) referenced.add(path.basename(row.icon)); + } + + // Space banners + const spaceBannerRows = db.select({ banner: schema.spaces.banner }) + .from(schema.spaces) + .where(isNotNull(schema.spaces.banner)) + .all(); + for (const row of spaceBannerRows) { + if (row.banner) referenced.add(path.basename(row.banner)); + } + + return referenced; +} + +function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] { + const db = getDb(); + const cutoff = Date.now() - UNLINKED_AGE_MS; + // Attachments with no message_id AND no dm_message_id, older than 1 hour + const rows = db.select({ + id: schema.attachments.id, + filename: schema.attachments.filename, + thumbnailFilename: schema.attachments.thumbnailFilename, + size: schema.attachments.size, + messageId: schema.attachments.messageId, + dmMessageId: schema.attachments.dmMessageId, + createdAt: schema.attachments.createdAt, + }).from(schema.attachments).all(); + + return rows.filter(r => + r.messageId === null && r.dmMessageId === null && r.createdAt < cutoff + ).map(r => ({ + id: r.id, + filename: r.filename, + thumbnailFilename: r.thumbnailFilename, + size: r.size, + })); +} + +export function getStorageStats(): StorageStats { + const diskFiles = getDiskFiles(); + const referenced = getReferencedFilenames(); + const unlinked = getUnlinkedAttachments(); + + let totalSize = 0; + let referencedSize = 0; + let orphanedFiles = 0; + let orphanedSize = 0; + const breakdownMap = new Map(); + + for (const file of diskFiles) { + totalSize += file.size; + const type = classifyFile(file.filename); + const entry = breakdownMap.get(type) || { count: 0, size: 0 }; + entry.count++; + entry.size += file.size; + breakdownMap.set(type, entry); + + if (referenced.has(file.filename)) { + referencedSize += file.size; + } else { + orphanedFiles++; + orphanedSize += file.size; + } + } + + let unlinkedSize = 0; + for (const att of unlinked) { + unlinkedSize += att.size; + } + + const breakdown: StorageBreakdown[] = []; + for (const [type, data] of breakdownMap) { + breakdown.push({ type, count: data.count, size: data.size }); + } + breakdown.sort((a, b) => b.size - a.size); + + return { + totalFiles: diskFiles.length, + totalSize, + referencedFiles: diskFiles.length - orphanedFiles, + referencedSize, + orphanedFiles, + orphanedSize, + unlinkedAttachments: unlinked.length, + unlinkedSize, + breakdown, + }; +} + +export function getOrphanedFiles(): OrphanedFile[] { + const diskFiles = getDiskFiles(); + const referenced = getReferencedFilenames(); + + const orphans: OrphanedFile[] = []; + for (const file of diskFiles) { + if (!referenced.has(file.filename)) { + orphans.push({ + filename: file.filename, + size: file.size, + modifiedAt: file.modifiedAt, + }); + } + } + + orphans.sort((a, b) => b.size - a.size); + return orphans; +} + +export function cleanupStorage(dryRun: boolean): CleanupResult { + const db = getDb(); + const orphans = getOrphanedFiles(); + const unlinked = getUnlinkedAttachments(); + const errors: string[] = []; + let deletedFiles = 0; + let freedBytes = 0; + let deletedAttachmentRecords = 0; + + // Delete orphaned disk files + for (const orphan of orphans) { + if (!dryRun) { + try { + deleteUploadFile(orphan.filename); + } catch (err: any) { + errors.push(`Failed to delete ${orphan.filename}: ${err.message}`); + continue; + } + } + deletedFiles++; + freedBytes += orphan.size; + } + + // Delete stale unlinked attachment records (and their disk files) + for (const att of unlinked) { + if (!dryRun) { + try { + deleteUploadFile(att.filename); + if (att.thumbnailFilename) { + deleteUploadFile(att.thumbnailFilename); + } + db.delete(schema.attachments) + .where(eq(schema.attachments.id, att.id)) + .run(); + } catch (err: any) { + errors.push(`Failed to clean up attachment ${att.id}: ${err.message}`); + continue; + } + } + deletedAttachmentRecords++; + freedBytes += att.size; + } + + return { + dryRun, + deletedFiles, + freedBytes, + deletedAttachmentRecords, + errors, + }; +} diff --git a/packages/server/src/utils/userDeletion.ts b/packages/server/src/utils/userDeletion.ts new file mode 100644 index 00000000..bdc10e70 --- /dev/null +++ b/packages/server/src/utils/userDeletion.ts @@ -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; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ced7e902..12cd6fb6 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -561,3 +561,63 @@ export interface DeleteAccountRequest { password: string; username: string; // Must match — confirmation safeguard } + +// ─── Storage Management Types ───────────────────────────────────────────── + +export interface StorageBreakdown { + type: string; // 'image' | 'video' | 'audio' | 'document' | 'other' + count: number; + size: number; +} + +export interface StorageStats { + totalFiles: number; + totalSize: number; + referencedFiles: number; + referencedSize: number; + orphanedFiles: number; + orphanedSize: number; + unlinkedAttachments: number; + unlinkedSize: number; + breakdown: StorageBreakdown[]; +} + +export interface OrphanedFile { + filename: string; + size: number; + modifiedAt: number; +} + +export interface CleanupResult { + dryRun: boolean; + deletedFiles: number; + freedBytes: number; + deletedAttachmentRecords: number; + errors: string[]; +} + +// ─── Admin User Management Types ────────────────────────────────────────── + +export interface AdminUser { + id: string; + username: string; + displayName: string | null; + avatar: string | null; + avatarColor: string | null; + status: string; + isAdmin: boolean; + isDeleted: boolean; + homeInstance: string | null; + createdAt: number; +} + +export interface AdminUserListResponse { + users: AdminUser[]; + total: number; + page: number; + pageSize: number; +} + +export interface AdminResetPasswordResponse { + temporaryPassword: string; +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 52e40843..d3812307 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -34,6 +34,12 @@ import type { ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, + StorageStats, + OrphanedFile, + CleanupResult, + AdminUserListResponse, + AdminUser, + AdminResetPasswordResponse, ExploreSpace, JoinRequest, Role, @@ -181,6 +187,16 @@ export class BackspaceApiClient { myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>; }; + readonly admin: { + storageStats: () => Promise; + storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; + storageCleanup: (dryRun?: boolean) => Promise; + listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean }) => Promise; + setUserRole: (userId: string, isAdmin: boolean) => Promise; + resetUserPassword: (userId: string) => Promise; + deleteUser: (userId: string) => Promise<{ success: boolean }>; + }; + constructor(baseUrl: string, getToken: () => string | null) { async function request( method: string, @@ -487,6 +503,26 @@ export class BackspaceApiClient { return request<{ requests: JoinRequest[] }>('GET', `/users/@me/join-requests?${params}`); }, }; + + this.admin = { + storageStats: () => request('GET', '/admin/storage/stats'), + storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), + storageCleanup: (dryRun = false) => request('POST', '/admin/storage/cleanup', { dryRun }), + listUsers: (params) => { + const qs = new URLSearchParams(); + if (params?.q) qs.set('q', params.q); + if (params?.page !== undefined) qs.set('page', String(params.page)); + if (params?.pageSize !== undefined) qs.set('pageSize', String(params.pageSize)); + if (params?.showDeleted) qs.set('showDeleted', 'true'); + return request('GET', `/admin/users?${qs}`); + }, + setUserRole: (userId, isAdmin) => + request('PATCH', `/admin/users/${userId}/role`, { isAdmin }), + resetUserPassword: (userId) => + request('POST', `/admin/users/${userId}/reset-password`), + deleteUser: (userId) => + request<{ success: boolean }>('DELETE', `/admin/users/${userId}`), + }; } } diff --git a/packages/web/src/components/JoinPage.tsx b/packages/web/src/components/JoinPage.tsx index 1663f5c6..72a42ef4 100644 --- a/packages/web/src/components/JoinPage.tsx +++ b/packages/web/src/components/JoinPage.tsx @@ -379,7 +379,7 @@ export function JoinPage() { value={otherDomain} onChange={(e) => setOtherDomain(e.target.value)} placeholder="e.g. my-instance.com" - className="flex-1 px-3 py-2.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + className="input-standard flex-1 py-2.5" autoFocus /> - +
-
+
{ e.preventDefault(); handleFallbackLogin(); }} className="space-y-2">
void }) { value={fallbackUsername} onChange={(e) => setFallbackUsername(e.target.value)} placeholder="Your username on this instance" - className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + className="input-standard w-full" disabled={isLoading} + autoComplete="username" />
@@ -217,21 +219,21 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { type="password" value={fallbackPassword} onChange={(e) => setFallbackPassword(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()} placeholder="Password on the remote instance" - className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + className="input-standard w-full" disabled={isLoading} autoFocus + autoComplete="current-password" />
-
+
)} - + )} ); diff --git a/packages/web/src/components/modals/CreateCategory.tsx b/packages/web/src/components/modals/CreateCategory.tsx index 16b35147..206edd95 100644 --- a/packages/web/src/components/modals/CreateCategory.tsx +++ b/packages/web/src/components/modals/CreateCategory.tsx @@ -57,7 +57,7 @@ export function CreateCategoryModal() { type="text" value={name} onChange={(e) => setName(e.target.value)} - className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors" + className="input-standard w-full" placeholder="new-category" autoFocus /> diff --git a/packages/web/src/components/modals/CreateChannel.tsx b/packages/web/src/components/modals/CreateChannel.tsx index cd08215e..2b4e5614 100644 --- a/packages/web/src/components/modals/CreateChannel.tsx +++ b/packages/web/src/components/modals/CreateChannel.tsx @@ -114,7 +114,7 @@ export function CreateChannelModal() { type="text" value={name} onChange={(e) => setName(e.target.value)} - className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors" + className="input-standard w-full" placeholder="new-channel" autoFocus /> @@ -129,7 +129,7 @@ export function CreateChannelModal() { type="text" value={topic} onChange={(e) => setTopic(e.target.value)} - className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors" + className="input-standard w-full" placeholder="What's this channel about?" /> @@ -143,7 +143,7 @@ export function CreateChannelModal() { handleTransferTo(space.id, e.target.value)} - className="w-full px-2.5 py-1.5 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + className="input-standard w-full px-2.5 py-1.5 text-xs" > {space.members.map(m => ( @@ -282,7 +282,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps) {/* Step 2: Confirmation */} {step === 'confirm' && ( - <> +
{ e.preventDefault(); handleConfirmDelete(); }} className="space-y-4">

This action is permanent and cannot be undone.

@@ -295,7 +295,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps) type="text" value={confirmUsername} onChange={(e) => 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" + className="input-danger w-full" placeholder={user.username} /> @@ -306,8 +306,9 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps) type="password" value={confirmPassword} onChange={(e) => 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" + className="input-danger w-full" placeholder="Enter your password" + autoComplete="current-password" /> @@ -323,14 +324,14 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps) Back - +
)} {/* Step 3: Federation Progress */} diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index d8c871a1..890e680e 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -67,7 +67,7 @@ export function InviteModal() { type="text" value={isLoading ? 'Generating...' : inviteUrl} readOnly - className="invite-code-input flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary outline-none font-mono text-xs" + className="input-standard invite-code-input flex-1 font-mono text-xs" /> + + ); + } + + if (!stats) return null; + + const hasOrphans = stats.orphanedFiles > 0 || stats.unlinkedAttachments > 0; + + return ( +
+
+ Monitor disk usage and clean up orphaned files left behind by deleted content or replaced avatars/banners. +
+ + {/* Storage Overview */} +
+
Storage Overview
+
+
+
Total Files
+
{stats.totalFiles}
+
{formatBytes(stats.totalSize)}
+
+
+
Referenced
+
{stats.referencedFiles}
+
{formatBytes(stats.referencedSize)}
+
+
+
Orphaned Files
+
0 ? 'text-accent-amber' : 'text-txt-primary'}`}> + {stats.orphanedFiles} +
+
{formatBytes(stats.orphanedSize)}
+
+
+
Unlinked Uploads
+
0 ? 'text-accent-amber' : 'text-txt-primary'}`}> + {stats.unlinkedAttachments} +
+
{formatBytes(stats.unlinkedSize)}
+
+
+
+ + {/* File Type Breakdown */} + {stats.breakdown.length > 0 && ( +
+
File Type Breakdown
+
+
+ {stats.breakdown.map((b) => ( +
+ {b.type} + + {b.count} file{b.count !== 1 ? 's' : ''} — {formatBytes(b.size)} + +
+ ))} +
+
+
+ )} + + {/* Cleanup Actions */} +
+
Cleanup
+
+ {!hasOrphans && ( +
No orphaned files or stale uploads found.
+ )} + + {hasOrphans && ( +
+ + +
+ )} + + {cleanupResult && ( +
+
+ {cleanupResult.dryRun ? 'Preview — no files deleted' : 'Cleanup complete'} +
+
+ {cleanupResult.deletedFiles} orphaned file{cleanupResult.deletedFiles !== 1 ? 's' : ''} ({formatBytes(cleanupResult.freedBytes)}) + {cleanupResult.deletedAttachmentRecords > 0 && ( + <>, {cleanupResult.deletedAttachmentRecords} stale upload record{cleanupResult.deletedAttachmentRecords !== 1 ? 's' : ''} + )} +
+ {cleanupResult.errors.length > 0 && ( +
+ {cleanupResult.errors.length} error{cleanupResult.errors.length !== 1 ? 's' : ''}: {cleanupResult.errors[0]} +
+ )} +
+ )} +
+
+ + {/* Error / Refresh */} + {error && ( +
{error}
+ )} + + +
+ ); +} diff --git a/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx index 32bdf322..c38f2b5d 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx @@ -142,7 +142,7 @@ export function StreamingPanel() { const v = Number(e.target.value); if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v }); }} - className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary" + className="input-standard w-24 px-2 py-1" /> kbps diff --git a/packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx new file mode 100644 index 00000000..b3238eb8 --- /dev/null +++ b/packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx @@ -0,0 +1,340 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { api } from '../../../api/client'; +import { Avatar } from '../../ui/Avatar'; +import { ConfirmDialog } from '../../ui/ConfirmDialog'; +import { useAuthStore } from '../../../stores/authStore'; +import type { AdminUser, AdminUserListResponse } from '@backspace/shared'; + +export function UsersPanel() { + const currentUser = useAuthStore((s) => s.user); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [query, setQuery] = useState(''); + const [showDeleted, setShowDeleted] = useState(false); + const [page, setPage] = useState(1); + const pageSize = 50; + + // Confirm dialogs + const [confirmAction, setConfirmAction] = useState<{ type: 'demote' | 'delete'; user: AdminUser } | null>(null); + const [actionLoading, setActionLoading] = useState(false); + + // Temp password display + const [tempPassword, setTempPassword] = useState<{ userId: string; password: string } | null>(null); + + const debounceRef = useRef>(); + + const fetchUsers = useCallback(async (q: string, p: number, deleted: boolean) => { + setLoading(true); + setError(''); + try { + const result = await api.admin.listUsers({ q: q || undefined, page: p, pageSize, showDeleted: deleted }); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchUsers(query, page, showDeleted); + }, [fetchUsers, page, showDeleted]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleSearchChange = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setPage(1); + fetchUsers(value, 1, showDeleted); + }, 300); + }; + + const handleToggleAdmin = async (user: AdminUser) => { + if (user.isAdmin) { + // Demoting — confirm first + setConfirmAction({ type: 'demote', user }); + return; + } + // Promoting — no confirm needed + setError(''); + try { + await api.admin.setUserRole(user.id, true); + fetchUsers(query, page, showDeleted); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update role'); + } + }; + + const handleResetPassword = async (user: AdminUser) => { + setError(''); + setTempPassword(null); + try { + const result = await api.admin.resetUserPassword(user.id); + setTempPassword({ userId: user.id, password: result.temporaryPassword }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reset password'); + } + }; + + const handleDeleteUser = (user: AdminUser) => { + setConfirmAction({ type: 'delete', user }); + }; + + const handleConfirm = async () => { + if (!confirmAction) return; + setActionLoading(true); + setError(''); + try { + if (confirmAction.type === 'demote') { + await api.admin.setUserRole(confirmAction.user.id, false); + } else { + await api.admin.deleteUser(confirmAction.user.id); + } + setConfirmAction(null); + fetchUsers(query, page, showDeleted); + } catch (err) { + setError(err instanceof Error ? err.message : 'Action failed'); + setConfirmAction(null); + } finally { + setActionLoading(false); + } + }; + + const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1; + + const formatDate = (ts: number) => { + const d = new Date(ts); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + }; + + return ( +
+
+ View and manage user accounts on this instance. +
+ + {/* Search + Show Deleted */} +
+ handleSearchChange(e.target.value)} + placeholder="Search users..." + className="input-search flex-1" + /> + +
+ + {/* Error */} + {error && ( +
{error}
+ )} + + {/* Temp password banner */} + {tempPassword && ( +
+
+
+ Temporary password for {data?.users.find(u => u.id === tempPassword.userId)?.username ?? 'user'}: +
+ +
+
+ + {tempPassword.password} + + +
+
+ This password is shown once. The user has been disconnected and must log in again. +
+
+ )} + + {/* Loading */} + {loading && !data && ( +
Loading users...
+ )} + + {/* User list */} + {data && ( +
+ {data.users.length === 0 && ( +
No users found
+ )} + {data.users.map((user) => { + const isSelf = user.id === currentUser?.id; + const isFederated = !!user.homeInstance; + const isDeleted = user.isDeleted; + + return ( +
+ {/* Avatar */} +
+ +
+ + {/* Info */} +
+
+ + {user.username} + + {user.displayName && !isDeleted && ( + {user.displayName} + )} +
+
+ {user.isAdmin && ( + + Admin + + )} + {isFederated && ( + + {user.homeInstance} + + )} + {isDeleted && ( + + Deleted + + )} + + {formatDate(user.createdAt)} + +
+
+ + {/* Actions */} + {!isDeleted && ( +
+ {/* Toggle admin */} + + + {/* Reset password */} + + + {/* Delete user */} + +
+ )} +
+ ); + })} +
+ )} + + {/* Pagination */} + {data && totalPages > 1 && ( +
+ + + Page {page} of {totalPages} ({data.total} user{data.total !== 1 ? 's' : ''}) + + +
+ )} + + {/* Confirm dialogs */} + setConfirmAction(null)} + onConfirm={handleConfirm} + title="Demote Admin" + description={<>Remove admin privileges from {confirmAction?.user.username}? They will lose access to instance settings.} + confirmLabel="Demote" + variant="warning" + loading={actionLoading} + /> + setConfirmAction(null)} + onConfirm={handleConfirm} + title="Delete User" + description={<>Permanently delete {confirmAction?.user.username}? This will remove them from all spaces, DMs, and friends lists. This cannot be undone.} + confirmLabel="Delete User" + variant="danger" + loading={actionLoading} + /> +
+ ); +} diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 92da1350..cde850c0 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -498,7 +498,7 @@ export function AccountPanel() { } }} placeholder="#hex" - className="w-24 px-2 py-1.5 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary font-mono" + className="input-standard w-24 px-2 py-1.5 text-xs font-mono" maxLength={7} /> {accentColor && ( @@ -530,7 +530,7 @@ export function AccountPanel() { }} rows={3} placeholder="Tell the world about yourself..." - 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 resize-none" + className="input-standard w-full resize-none" maxLength={190} /> @@ -550,7 +550,7 @@ export function AccountPanel() {
@@ -592,8 +593,9 @@ export function AccountPanel() { type={showCurrentPassword ? 'text' : 'password'} value={currentPassword} onChange={(e) => 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" + className="input-standard w-full pr-10" placeholder="Enter current password" + autoComplete="current-password" />
@@ -674,13 +678,13 @@ export function AccountPanel() { )} -
+ {/* ── Danger Zone ── */} diff --git a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx index a3a5ece5..c10779a0 100644 --- a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx @@ -2,8 +2,10 @@ import { useState, useEffect } from 'react'; import { useSettingsStore } from '../../../stores/settingsStore'; import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel'; import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel'; +import { StoragePanel } from '../instanceSettingsPanels/StoragePanel'; +import { UsersPanel } from '../instanceSettingsPanels/UsersPanel'; -type SubTab = 'general' | 'streaming'; +type SubTab = 'general' | 'streaming' | 'storage' | 'users'; export function InstancePanel() { const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); @@ -33,11 +35,19 @@ export function InstancePanel() { + + {/* Content */} {subTab === 'general' && } {subTab === 'streaming' && } + {subTab === 'storage' && } + {subTab === 'users' && } ); } diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index 06c35a3f..0b7a91cd 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -437,7 +437,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { type="text" value={spaceName} onChange={(e) => setSpaceName(e.target.value)} - className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + className="input-standard w-full" disabled={!canManageSpace} /> @@ -494,7 +494,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { value={transferSearch} onChange={(e) => setTransferSearch(e.target.value)} placeholder="Search members..." - className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50" + className="input-search w-full" autoFocus />
@@ -503,7 +503,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { ) : ( transferCandidates.map((member) => { const avatarUrl = member.user.avatar - ? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) + ? (member.user.avatar.startsWith('http') || member.user.avatar.startsWith('/') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) : null; return (
@@ -312,7 +312,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) { const v = e.target.value; if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setDraftColor(v); }} - className="w-20 px-2 py-1 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary font-mono" + className="input-standard w-20 px-2 py-1 text-xs font-mono" maxLength={7} />
diff --git a/packages/web/src/components/ui/Avatar.tsx b/packages/web/src/components/ui/Avatar.tsx index 9a2a57e7..e6f03a5c 100644 --- a/packages/web/src/components/ui/Avatar.tsx +++ b/packages/web/src/components/ui/Avatar.tsx @@ -102,7 +102,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick, > {src ? (