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
+27 -2
View File
@@ -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. **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 ## 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. 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 │ │ │ ├── seed.ts
│ │ │ └── migrate.ts │ │ │ └── migrate.ts
│ │ ├── routes/ │ │ ├── routes/
│ │ │ ├── admin.ts
│ │ │ ├── auth.ts │ │ │ ├── auth.ts
│ │ │ ├── users.ts │ │ │ ├── users.ts
│ │ │ ├── spaces.ts │ │ │ ├── spaces.ts
@@ -134,7 +148,8 @@ Backspace/
│ │ ├── snowflake.ts │ │ ├── snowflake.ts
│ │ ├── permissions.ts │ │ ├── permissions.ts
│ │ ├── sanitize.ts │ │ ├── sanitize.ts
│ │ ── fileCleanup.ts │ │ ── fileCleanup.ts
│ │ └── storageJanitor.ts
│ ├── web/ │ ├── web/
│ │ ├── package.json │ │ ├── package.json
│ │ ├── tsconfig.json │ │ ├── tsconfig.json
@@ -252,7 +267,8 @@ Backspace/
│ │ │ │ │ └── BansPanel.tsx │ │ │ │ │ └── BansPanel.tsx
│ │ │ │ └── instanceSettingsPanels/ │ │ │ │ └── instanceSettingsPanels/
│ │ │ │ ├── GeneralPanel.tsx │ │ │ │ ├── GeneralPanel.tsx
│ │ │ │ ── StreamingPanel.tsx │ │ │ │ ── StreamingPanel.tsx
│ │ │ │ └── StoragePanel.tsx
│ │ │ └── ui/ │ │ │ └── ui/
│ │ │ ├── Avatar.tsx │ │ │ ├── Avatar.tsx
│ │ │ ├── Modal.tsx │ │ │ ├── Modal.tsx
@@ -650,6 +666,15 @@ PATCH /api/settings/streaming (auth, admin) { maxBitrateKbps?, ... } → {
GET /api/settings/instance (auth, admin) → { instanceName, registrationOpen, discoveryEnabled } GET /api/settings/instance (auth, admin) → { instanceName, registrationOpen, discoveryEnabled }
PATCH /api/settings/instance (auth, admin) { instanceName?, registrationOpen?, discoveryEnabled? } → { settings } 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 # Utilities
GET /api/utils/metadata (auth) ?url= → { title?, description?, image?, siteName? } GET /api/utils/metadata (auth) ?url= → { title?, description?, image?, siteName? }
GET /api/health (public) → { status: 'ok', timestamp } GET /api/health (public) → { status: 'ok', timestamp }
+2
View File
@@ -22,6 +22,7 @@ import { utilRoutes } from './routes/utils.js';
import { instanceRoutes } from './routes/instance.js'; import { instanceRoutes } from './routes/instance.js';
import { exploreRoutes } from './routes/explore.js'; import { exploreRoutes } from './routes/explore.js';
import { searchRoutes } from './routes/search.js'; import { searchRoutes } from './routes/search.js';
import { adminRoutes } from './routes/admin.js';
import { registerWebSocket } from './ws/handler.js'; import { registerWebSocket } from './ws/handler.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@@ -88,6 +89,7 @@ async function main(): Promise<void> {
await app.register(instanceRoutes); await app.register(instanceRoutes);
await app.register(exploreRoutes); await app.register(exploreRoutes);
await app.register(searchRoutes); await app.register(searchRoutes);
await app.register(adminRoutes);
await app.register(registerWebSocket); await app.register(registerWebSocket);
app.get('/api/health', async () => { app.get('/api/health', async () => {
+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 type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; 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 { config } from '../config.js';
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared'; 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 // 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(); 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 body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() }; 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 // 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 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(); const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!row) { if (!row) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 }); 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 // 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 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 body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() }; const updateData: Record<string, number | string> = { updatedAt: Date.now() };
+14 -119
View File
@@ -1,13 +1,13 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { eq, or, and, inArray } from 'drizzle-orm'; import { eq, or, and, inArray } from 'drizzle-orm';
import crypto from 'crypto';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js'; import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
import { AVATAR_COLORS } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js'; 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'; import { generateSnowflake } from '../utils/snowflake.js';
export async function userRoutes(app: FastifyInstance): Promise<void> { export async function userRoutes(app: FastifyInstance): Promise<void> {
@@ -125,123 +125,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
}); });
} }
const uid = request.userId; // Tombstone the account (transaction handles all DB cleanup)
const filesToDelete = tombstoneUser(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();
});
// Clean up files from disk after transaction commits // Clean up files from disk after transaction commits
for (const filename of filesToDelete) { for (const filename of filesToDelete) {
@@ -569,6 +454,16 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
.set({ name: folder.name, color: folder.color }) .set({ name: folder.name, color: folder.color })
.where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId))) .where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId)))
.run(); .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 // Clear and re-insert folder members with position
+13
View File
@@ -1,6 +1,8 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { eq } from 'drizzle-orm';
import { config } from '../config.js'; import { config } from '../config.js';
import { getDb, schema } from '../db/index.js';
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
const SALT_ROUNDS = 12; const SALT_ROUNDS = 12;
@@ -50,6 +52,17 @@ export async function authenticate(
} }
} }
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
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' { declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
userId: string; userId: string;
+259
View File
@@ -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<string> {
const db = getDb();
const referenced = new Set<string>();
// 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<string, { count: number; size: number }>();
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,
};
}
+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;
}
+60
View File
@@ -561,3 +561,63 @@ export interface DeleteAccountRequest {
password: string; password: string;
username: string; // Must match — confirmation safeguard 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;
}
+36
View File
@@ -34,6 +34,12 @@ import type {
ChangePasswordRequest, ChangePasswordRequest,
ChangePasswordResponse, ChangePasswordResponse,
DeleteAccountRequest, DeleteAccountRequest,
StorageStats,
OrphanedFile,
CleanupResult,
AdminUserListResponse,
AdminUser,
AdminResetPasswordResponse,
ExploreSpace, ExploreSpace,
JoinRequest, JoinRequest,
Role, Role,
@@ -181,6 +187,16 @@ export class BackspaceApiClient {
myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>; myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>;
}; };
readonly admin: {
storageStats: () => Promise<StorageStats>;
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
storageCleanup: (dryRun?: boolean) => Promise<CleanupResult>;
listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean }) => Promise<AdminUserListResponse>;
setUserRole: (userId: string, isAdmin: boolean) => Promise<AdminUser>;
resetUserPassword: (userId: string) => Promise<AdminResetPasswordResponse>;
deleteUser: (userId: string) => Promise<{ success: boolean }>;
};
constructor(baseUrl: string, getToken: () => string | null) { constructor(baseUrl: string, getToken: () => string | null) {
async function request<T>( async function request<T>(
method: string, method: string,
@@ -487,6 +503,26 @@ export class BackspaceApiClient {
return request<{ requests: JoinRequest[] }>('GET', `/users/@me/join-requests?${params}`); return request<{ requests: JoinRequest[] }>('GET', `/users/@me/join-requests?${params}`);
}, },
}; };
this.admin = {
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
storageCleanup: (dryRun = false) => request<CleanupResult>('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<AdminUserListResponse>('GET', `/admin/users?${qs}`);
},
setUserRole: (userId, isAdmin) =>
request<AdminUser>('PATCH', `/admin/users/${userId}/role`, { isAdmin }),
resetUserPassword: (userId) =>
request<AdminResetPasswordResponse>('POST', `/admin/users/${userId}/reset-password`),
deleteUser: (userId) =>
request<{ success: boolean }>('DELETE', `/admin/users/${userId}`),
};
} }
} }
+8 -4
View File
@@ -379,7 +379,7 @@ export function JoinPage() {
value={otherDomain} value={otherDomain}
onChange={(e) => setOtherDomain(e.target.value)} onChange={(e) => setOtherDomain(e.target.value)}
placeholder="e.g. my-instance.com" 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 autoFocus
/> />
<button <button
@@ -406,6 +406,7 @@ export function JoinPage() {
{/* Phase: connect — password prompt for federation */} {/* Phase: connect — password prompt for federation */}
{phase === 'connect' && ( {phase === 'connect' && (
<form onSubmit={handleConnect}> <form onSubmit={handleConnect}>
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
{/* Identity card */} {/* Identity card */}
<div className="flex items-center gap-3 bg-surface-input rounded-lg p-3 mb-3"> <div className="flex items-center gap-3 bg-surface-input rounded-lg p-3 mb-3">
<Avatar <Avatar
@@ -431,9 +432,10 @@ export function JoinPage() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password" placeholder="Your account password"
className="w-full 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 w-full py-2.5"
disabled={isJoining} disabled={isJoining}
autoFocus autoFocus
autoComplete="current-password"
/> />
<p className="text-xs text-txt-tertiary mt-1"> <p className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance. Your password is verified locally, then used to create or access your account on the remote instance.
@@ -477,8 +479,9 @@ export function JoinPage() {
value={fallbackUsername} value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)} onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance" placeholder="Your username on this instance"
className="w-full 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 w-full py-2.5"
disabled={isJoining} disabled={isJoining}
autoComplete="username"
/> />
</div> </div>
<div> <div>
@@ -488,9 +491,10 @@ export function JoinPage() {
value={fallbackPassword} value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)} onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance" placeholder="Password on the remote instance"
className="w-full 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 w-full py-2.5"
disabled={isJoining} disabled={isJoining}
autoFocus autoFocus
autoComplete="current-password"
/> />
</div> </div>
</div> </div>
@@ -91,7 +91,7 @@ export function LoginPage() {
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" className="input-standard w-full py-2.5"
autoFocus autoFocus
autoComplete="username" autoComplete="username"
/> />
@@ -105,7 +105,7 @@ export function LoginPage() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" className="input-standard w-full py-2.5"
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </div>
@@ -257,7 +257,7 @@ export function RegisterPage() {
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())} onChange={(e) => setUsername(e.target.value.toLowerCase())}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" className="input-standard w-full py-2.5"
autoFocus autoFocus
autoComplete="username" autoComplete="username"
/> />
@@ -296,7 +296,7 @@ export function RegisterPage() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" className="input-standard w-full py-2.5"
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
@@ -309,7 +309,7 @@ export function RegisterPage() {
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" className="input-standard w-full py-2.5"
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
@@ -396,7 +396,7 @@ export function RegisterPage() {
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
placeholder={username.trim() || 'Display name'} placeholder={username.trim() || 'Display name'}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all placeholder:text-txt-tertiary" className="input-standard w-full py-2.5"
autoComplete="name" autoComplete="name"
/> />
</div> </div>
@@ -83,7 +83,7 @@ export function ExplorePage() {
placeholder="Search spaces..." placeholder="Search spaces..."
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all" className="input-search w-full"
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -230,10 +230,10 @@ function SpaceCard({
: null; : null;
const iconUrl = space.icon const iconUrl = space.icon
? (space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`) ? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`)
: null; : null;
const bannerUrl = space.banner const bannerUrl = space.banner
? (space.banner.startsWith('http') ? space.banner : `/api/uploads/${space.banner}`) ? (space.banner.startsWith('http') || space.banner.startsWith('/') ? space.banner : `/api/uploads/${space.banner}`)
: null; : null;
// Extract dominant colors from icon when no banner is set // Extract dominant colors from icon when no banner is set
@@ -412,7 +412,7 @@ function SpaceCard({
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))} onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
placeholder="Why do you want to join? (optional)" placeholder="Why do you want to join? (optional)"
rows={2} rows={2}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary resize-none placeholder:text-txt-tertiary" className="input-standard w-full resize-none"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -268,7 +268,7 @@ function AddFriendTab({
placeholder="You can add a friend with their username" placeholder="You can add a friend with their username"
value={addUsername} value={addUsername}
onChange={(e) => setAddUsername(e.target.value)} onChange={(e) => setAddUsername(e.target.value)}
className="w-full bg-surface-base text-txt-primary px-4 py-3 rounded-lg border border-transparent focus:border-txt-link outline-none transition-all placeholder:text-txt-tertiary/50" className="input-search w-full px-4 py-3 rounded-lg"
/> />
<button <button
type="submit" type="submit"
@@ -301,7 +301,7 @@ function AddFriendTab({
placeholder="Search people..." placeholder="Search people..."
value={discoverQuery} value={discoverQuery}
onChange={(e) => handleDiscoverSearch(e.target.value)} onChange={(e) => handleDiscoverSearch(e.target.value)}
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all" className="input-search w-full"
/> />
{discoverQuery && ( {discoverQuery && (
<button <button
@@ -371,10 +371,10 @@ function UserDiscoverCard({
: null; : null;
const avatarUrl = user.avatar const avatarUrl = user.avatar
? (user.avatar.startsWith('http') ? user.avatar : `/api/uploads/${user.avatar}`) ? (user.avatar.startsWith('http') || user.avatar.startsWith('/') ? user.avatar : `/api/uploads/${user.avatar}`)
: null; : null;
const bannerUrl = user.banner const bannerUrl = user.banner
? (user.banner.startsWith('http') ? user.banner : `/api/uploads/${user.banner}`) ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : `/api/uploads/${user.banner}`)
: null; : null;
const handleSendRequest = async () => { const handleSendRequest = async () => {
+3 -3
View File
@@ -229,7 +229,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
value={editContent} value={editContent}
onChange={(e) => setEditContent(e.target.value)} onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditSubmit} onKeyDown={handleEditSubmit}
className="w-full p-3 bg-surface-input rounded-lg text-txt-primary outline-none resize-none text-[15px] leading-[1.5] shadow-inner" className="input-standard w-full p-3 rounded-lg resize-none text-[15px] leading-[1.5] shadow-inner"
rows={2} rows={2}
autoFocus autoFocus
/> />
@@ -262,9 +262,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div className="mt-1 grid gap-2"> <div className="mt-1 grid gap-2">
{message.attachments.map((att) => { {message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/'); const isImage = att.mimetype.startsWith('image/');
const attUrl = att.filename.startsWith('http') ? att.filename : `/api/uploads/${att.filename}`; const attUrl = att.filename.startsWith('http') || att.filename.startsWith('/') ? att.filename : `/api/uploads/${att.filename}`;
const thumbUrl = att.thumbnailFilename const thumbUrl = att.thumbnailFilename
? (att.thumbnailFilename.startsWith('http') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`) ? (att.thumbnailFilename.startsWith('http') || att.thumbnailFilename.startsWith('/') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`)
: null; : null;
if (isImage) { if (isImage) {
return ( return (
@@ -349,7 +349,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined} onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`} placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
className="flex-1 py-[10px] px-1 bg-transparent text-txt-primary placeholder-txt-tertiary/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin" className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1} rows={1}
/> />
@@ -172,7 +172,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..." placeholder="Search messages..."
className="flex-1 bg-transparent text-txt-primary text-[14px] placeholder-txt-tertiary outline-none" className="input-embedded flex-1 text-[14px]"
/> />
{query && ( {query && (
<button <button
@@ -210,7 +210,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
value={fromFilter} value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)} onChange={(e) => setFromFilter(e.target.value)}
placeholder="username" placeholder="username"
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary placeholder-txt-tertiary outline-none" className="input-search w-full px-2 py-1 text-[13px]"
/> />
</div> </div>
<div> <div>
@@ -218,7 +218,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
<select <select
value={hasFilter} value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)} onChange={(e) => setHasFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none appearance-none cursor-pointer" className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
> >
<option value="">Any</option> <option value="">Any</option>
<option value="file">File</option> <option value="file">File</option>
@@ -232,7 +232,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="date" type="date"
value={beforeFilter} value={beforeFilter}
onChange={(e) => setBeforeFilter(e.target.value)} onChange={(e) => setBeforeFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none" className="input-search w-full px-2 py-1 text-[13px]"
/> />
</div> </div>
<div> <div>
@@ -241,7 +241,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="date" type="date"
value={afterFilter} value={afterFilter}
onChange={(e) => setAfterFilter(e.target.value)} onChange={(e) => setAfterFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none" className="input-search w-full px-2 py-1 text-[13px]"
/> />
</div> </div>
</div> </div>
@@ -370,7 +370,7 @@ export function DmSearchBar() {
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }} onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search..." placeholder="Search..."
className="flex-1 min-w-0 bg-transparent text-txt-primary placeholder-txt-tertiary/60 text-[13px] font-medium outline-none py-[5px]" className="input-embedded flex-1 min-w-0 text-[13px] font-medium py-[5px]"
/> />
</div> </div>
) : ( ) : (
@@ -126,7 +126,7 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
) )
) : icon ? ( ) : icon ? (
<img <img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`} src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
alt={name} alt={name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -200,7 +200,7 @@ function MiniSpaceIcon({ space }: { space: TaggedSpace }) {
if (icon) { if (icon) {
return ( return (
<img <img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`} src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
alt="" alt=""
className="w-full h-full object-cover rounded-[3px]" className="w-full h-full object-cover rounded-[3px]"
/> />
@@ -482,7 +482,7 @@ function FolderFlyout({
{isRenaming ? ( {isRenaming ? (
<input <input
autoFocus autoFocus
className="w-full bg-surface-input text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-accent-mint/40" className="input-search w-full px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary"
defaultValue={folder.name || ''} defaultValue={folder.name || ''}
onBlur={(e) => onRename(e.currentTarget.value)} onBlur={(e) => onRename(e.currentTarget.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -537,7 +537,7 @@ function FolderFlyout({
<div className="w-8 h-8 rounded-[10px] flex-shrink-0 overflow-hidden flex items-center justify-center" style={grad ? { background: grad.gradient } : undefined}> <div className="w-8 h-8 rounded-[10px] flex-shrink-0 overflow-hidden flex items-center justify-center" style={grad ? { background: grad.gradient } : undefined}>
{icon ? ( {icon ? (
<img <img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`} src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -97,7 +97,7 @@ export function AddDmMemberModal() {
value={query} value={query}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
placeholder="Search for a user..." placeholder="Search for a user..."
className="w-full px-3 py-2 bg-surface-input text-txt-primary placeholder-txt-tertiary/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-accent-primary" className="input-search w-full py-2 text-[14px]"
disabled={memberCount >= 10} disabled={memberCount >= 10}
/> />
@@ -638,7 +638,7 @@ function PermissionsTab({
value={memberSearch} value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)} onChange={(e) => setMemberSearch(e.target.value)}
placeholder="Search members..." placeholder="Search members..."
className="w-full px-2.5 py-1.5 text-sm bg-surface-input rounded mb-1 text-txt-primary placeholder-txt-muted outline-none" className="input-search w-full mb-1"
autoFocus autoFocus
/> />
</div> </div>
@@ -106,7 +106,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && url.trim() && handleProbe()} onKeyDown={(e) => e.key === 'Enter' && !isLoading && url.trim() && handleProbe()}
placeholder="https://instance.example.com" placeholder="https://instance.example.com"
className="flex-1 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 flex-1"
disabled={isLoading} disabled={isLoading}
/> />
<button <button
@@ -138,7 +138,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</div> </div>
</div> </div>
<div className="space-y-2"> <form onSubmit={(e) => { e.preventDefault(); handleConnect(); }} className="space-y-2">
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
<div> <div>
<label className="block text-xs text-txt-tertiary mb-1"> <label className="block text-xs text-txt-tertiary mb-1">
Enter your password to connect to {new URL(probeResult.origin).host} Enter your password to connect to {new URL(probeResult.origin).host}
@@ -147,24 +148,24 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()}
placeholder="Your account password" placeholder="Your account password"
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} disabled={isLoading}
autoFocus autoFocus
autoComplete="current-password"
/> />
<div className="text-xs text-txt-tertiary mt-1"> <div className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance. Your password is verified locally, then used to create or access your account on the remote instance.
</div> </div>
</div> </div>
<button <button
onClick={handleConnect} type="submit"
disabled={isLoading || !password} disabled={isLoading || !password}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
> >
{isLoading ? 'Connecting...' : 'Connect'} {isLoading ? 'Connecting...' : 'Connect'}
</button> </button>
</div> </form>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -199,7 +200,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
An account already exists on this instance with a different password. Enter the credentials you used on that instance. An account already exists on this instance with a different password. Enter the credentials you used on that instance.
</div> </div>
<div className="space-y-2"> <form onSubmit={(e) => { e.preventDefault(); handleFallbackLogin(); }} className="space-y-2">
<div> <div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label> <label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input <input
@@ -207,8 +208,9 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
value={fallbackUsername} value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)} onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance" 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} disabled={isLoading}
autoComplete="username"
/> />
</div> </div>
<div> <div>
@@ -217,21 +219,21 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
type="password" type="password"
value={fallbackPassword} value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)} onChange={(e) => setFallbackPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
placeholder="Password on the remote instance" 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} disabled={isLoading}
autoFocus autoFocus
autoComplete="current-password"
/> />
</div> </div>
<button <button
onClick={handleFallbackLogin} type="submit"
disabled={isLoading || !fallbackUsername || !fallbackPassword} disabled={isLoading || !fallbackUsername || !fallbackPassword}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
> >
{isLoading ? 'Logging in...' : 'Login & Connect'} {isLoading ? 'Logging in...' : 'Login & Connect'}
</button> </button>
</div> </form>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -345,26 +347,28 @@ function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').Conn
{/* Inline re-authentication prompt */} {/* Inline re-authentication prompt */}
{showReauth && ( {showReauth && (
<div className="space-y-2 pt-1"> <form onSubmit={(e) => { e.preventDefault(); handleReauth(); }} className="space-y-2 pt-1">
<input type="text" autoComplete="username" value={inst.username} readOnly tabIndex={-1} className="sr-only" />
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="password" type="password"
value={reauthPassword} value={reauthPassword}
onChange={(e) => setReauthPassword(e.target.value)} onChange={(e) => setReauthPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !reauthLoading && reauthPassword && handleReauth()}
placeholder="Your account password" placeholder="Your account password"
className="flex-1 px-3 py-1.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-1.5"
disabled={reauthLoading} disabled={reauthLoading}
autoFocus autoFocus
autoComplete="current-password"
/> />
<button <button
onClick={handleReauth} type="submit"
disabled={reauthLoading || !reauthPassword} disabled={reauthLoading || !reauthPassword}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50" className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50"
> >
{reauthLoading ? 'Connecting...' : 'Connect'} {reauthLoading ? 'Connecting...' : 'Connect'}
</button> </button>
<button <button
type="button"
onClick={() => { setShowReauth(false); setReauthPassword(''); setReauthError(''); }} onClick={() => { setShowReauth(false); setReauthPassword(''); setReauthError(''); }}
className="px-2 py-1.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors" className="px-2 py-1.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
> >
@@ -376,7 +380,7 @@ function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').Conn
{reauthError} {reauthError}
</div> </div>
)} )}
</div> </form>
)} )}
</div> </div>
); );
@@ -57,7 +57,7 @@ export function CreateCategoryModal() {
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} 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" placeholder="new-category"
autoFocus autoFocus
/> />
@@ -114,7 +114,7 @@ export function CreateChannelModal() {
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} 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" placeholder="new-channel"
autoFocus autoFocus
/> />
@@ -129,7 +129,7 @@ export function CreateChannelModal() {
type="text" type="text"
value={topic} value={topic}
onChange={(e) => setTopic(e.target.value)} 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?" placeholder="What's this channel about?"
/> />
</div> </div>
@@ -143,7 +143,7 @@ export function CreateChannelModal() {
<select <select
value={categoryId} value={categoryId}
onChange={(e) => setCategoryId(e.target.value)} onChange={(e) => setCategoryId(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"
> >
<option value="">No Category</option> <option value="">No Category</option>
{[...categories].sort((a, b) => a.position - b.position).map((cat) => ( {[...categories].sort((a, b) => a.position - b.position).map((cat) => (
@@ -215,7 +215,7 @@ export function CreateSpaceModal() {
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(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"
placeholder="My Awesome Space" placeholder="My Awesome Space"
autoFocus autoFocus
/> />
@@ -263,7 +263,7 @@ export function CreateSpaceModal() {
onChange={(e) => setDescription(e.target.value.slice(0, 200))} onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for your space..." placeholder="A short description for your space..."
rows={3} rows={3}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary resize-none placeholder:text-txt-tertiary" className="input-standard w-full resize-none"
/> />
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div> <div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div> </div>
@@ -247,7 +247,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
<select <select
value={space.transferTo} value={space.transferTo}
onChange={(e) => handleTransferTo(space.id, e.target.value)} onChange={(e) => 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"
> >
<option value="">Select new owner...</option> <option value="">Select new owner...</option>
{space.members.map(m => ( {space.members.map(m => (
@@ -282,7 +282,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
{/* Step 2: Confirmation */} {/* Step 2: Confirmation */}
{step === 'confirm' && ( {step === 'confirm' && (
<> <form onSubmit={(e) => { e.preventDefault(); handleConfirmDelete(); }} className="space-y-4">
<div className="bg-accent-rose/10 border border-accent-rose/20 rounded-lg p-3.5"> <div className="bg-accent-rose/10 border border-accent-rose/20 rounded-lg p-3.5">
<p className="text-sm text-txt-danger font-medium">This action is permanent and cannot be undone.</p> <p className="text-sm text-txt-danger font-medium">This action is permanent and cannot be undone.</p>
</div> </div>
@@ -295,7 +295,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
type="text" type="text"
value={confirmUsername} value={confirmUsername}
onChange={(e) => setConfirmUsername(e.target.value)} 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} placeholder={user.username}
/> />
</div> </div>
@@ -306,8 +306,9 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} 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" placeholder="Enter your password"
autoComplete="current-password"
/> />
</div> </div>
@@ -323,14 +324,14 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
Back Back
</button> </button>
<button <button
onClick={handleConfirmDelete} type="submit"
disabled={isLoading || confirmUsername !== user.username || !confirmPassword} disabled={isLoading || confirmUsername !== user.username || !confirmPassword}
className="flex-1 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isLoading ? 'Deleting...' : 'Delete My Account'} {isLoading ? 'Deleting...' : 'Delete My Account'}
</button> </button>
</div> </div>
</> </form>
)} )}
{/* Step 3: Federation Progress */} {/* Step 3: Federation Progress */}
@@ -67,7 +67,7 @@ export function InviteModal() {
type="text" type="text"
value={isLoading ? 'Generating...' : inviteUrl} value={isLoading ? 'Generating...' : inviteUrl}
readOnly 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"
/> />
<button <button
onClick={handleCopy} onClick={handleCopy}
@@ -146,7 +146,7 @@ export function JoinSpaceModal() {
type="text" type="text"
value={inviteCode} value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)} onChange={(e) => setInviteCode(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"
placeholder="e.g. abc123 or https://instance.com/join/abc123" placeholder="e.g. abc123 or https://instance.com/join/abc123"
autoFocus autoFocus
/> />
@@ -177,6 +177,7 @@ export function JoinSpaceModal() {
{/* Phase: connect — password prompt to connect to remote instance */} {/* Phase: connect — password prompt to connect to remote instance */}
{phase === 'connect' && ( {phase === 'connect' && (
<form onSubmit={handleConnect}> <form onSubmit={handleConnect}>
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
<p className="text-txt-secondary text-sm mb-4"> <p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space. Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
</p> </p>
@@ -190,9 +191,10 @@ export function JoinSpaceModal() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password" placeholder="Your account password"
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} disabled={isLoading}
autoFocus autoFocus
autoComplete="current-password"
/> />
<div className="text-xs text-txt-tertiary mt-1"> <div className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance. Your password is verified locally, then used to create or access your account on the remote instance.
@@ -247,8 +249,9 @@ export function JoinSpaceModal() {
value={fallbackUsername} value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)} onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance" 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} disabled={isLoading}
autoComplete="username"
/> />
</div> </div>
<div> <div>
@@ -258,9 +261,10 @@ export function JoinSpaceModal() {
value={fallbackPassword} value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)} onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance" 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} disabled={isLoading}
autoFocus autoFocus
autoComplete="current-password"
/> />
</div> </div>
</div> </div>
@@ -87,7 +87,7 @@ export function NewDmModal() {
value={query} value={query}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
placeholder="Search for a user..." placeholder="Search for a user..."
className="w-full px-3 py-2 bg-surface-input text-txt-primary placeholder-txt-tertiary/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-accent-primary" className="input-search w-full py-2 text-[14px]"
/> />
{error && ( {error && (
@@ -116,7 +116,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
onChange={(e) => setDescription(e.target.value.slice(0, 200))} onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for the Explore page..." placeholder="A short description for the Explore page..."
rows={3} rows={3}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary resize-none placeholder:text-txt-tertiary" className="input-standard w-full resize-none"
/> />
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div> <div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div> </div>
@@ -120,7 +120,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search members..." 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 autoFocus
/> />
</div> </div>
@@ -130,7 +130,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
) : ( ) : (
filteredMembers.map((member) => { filteredMembers.map((member) => {
const avatarUrl = member.user.avatar 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; : null;
return ( return (
<button <button
@@ -83,7 +83,7 @@ export function UserSettingsModal() {
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin"> <div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin px-1">
{tab === 'account' && <AccountPanel />} {tab === 'account' && <AccountPanel />}
{tab === 'voice' && <VoicePanel />} {tab === 'voice' && <VoicePanel />}
{tab === 'privacy' && <PrivacyPanel />} {tab === 'privacy' && <PrivacyPanel />}
@@ -56,7 +56,7 @@ export function GeneralPanel() {
value={draft.instanceName} value={draft.instanceName}
onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })} onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })}
placeholder="Backspace" placeholder="Backspace"
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary placeholder:text-txt-tertiary" className="input-standard w-full"
/> />
<div className="text-[11px] text-txt-tertiary text-right mt-1">{draft.instanceName.length}/32</div> <div className="text-[11px] text-txt-tertiary text-right mt-1">{draft.instanceName.length}/32</div>
</div> </div>
@@ -0,0 +1,196 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../../api/client';
import type { StorageStats, CleanupResult } from '@backspace/shared';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value < 10 ? value.toFixed(2) : value < 100 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
export function StoragePanel() {
const [stats, setStats] = useState<StorageStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [cleaning, setCleaning] = useState(false);
const [previewDone, setPreviewDone] = useState(false);
const fetchStats = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await api.admin.storageStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load storage stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const handleCleanup = async (dryRun: boolean) => {
setCleaning(true);
setCleanupResult(null);
setError('');
try {
const result = await api.admin.storageCleanup(dryRun);
setCleanupResult(result);
if (dryRun) {
setPreviewDone(true);
} else {
setPreviewDone(false);
await fetchStats();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Cleanup failed');
} finally {
setCleaning(false);
}
};
if (loading) {
return <div className="text-sm text-txt-tertiary">Loading storage stats...</div>;
}
if (error && !stats) {
return (
<div className="space-y-3">
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
<button onClick={fetchStats} className="text-sm text-accent-primary hover:underline">Retry</button>
</div>
);
}
if (!stats) return null;
const hasOrphans = stats.orphanedFiles > 0 || stats.unlinkedAttachments > 0;
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Monitor disk usage and clean up orphaned files left behind by deleted content or replaced avatars/banners.
</div>
{/* Storage Overview */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Storage Overview</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Total Files</div>
<div className="text-lg font-semibold text-txt-primary">{stats.totalFiles}</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.totalSize)}</div>
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Referenced</div>
<div className="text-lg font-semibold text-txt-primary">{stats.referencedFiles}</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.referencedSize)}</div>
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Orphaned Files</div>
<div className={`text-lg font-semibold ${stats.orphanedFiles > 0 ? 'text-accent-amber' : 'text-txt-primary'}`}>
{stats.orphanedFiles}
</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.orphanedSize)}</div>
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Unlinked Uploads</div>
<div className={`text-lg font-semibold ${stats.unlinkedAttachments > 0 ? 'text-accent-amber' : 'text-txt-primary'}`}>
{stats.unlinkedAttachments}
</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.unlinkedSize)}</div>
</div>
</div>
</div>
{/* File Type Breakdown */}
{stats.breakdown.length > 0 && (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">File Type Breakdown</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="space-y-1.5">
{stats.breakdown.map((b) => (
<div key={b.type} className="flex items-center justify-between text-sm">
<span className="text-txt-secondary capitalize">{b.type}</span>
<span className="text-txt-tertiary">
{b.count} file{b.count !== 1 ? 's' : ''} {formatBytes(b.size)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Cleanup Actions */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Cleanup</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-3">
{!hasOrphans && (
<div className="text-sm text-txt-tertiary">No orphaned files or stale uploads found.</div>
)}
{hasOrphans && (
<div className="flex gap-2">
<button
onClick={() => handleCleanup(true)}
disabled={cleaning}
className="px-3 py-1.5 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{cleaning ? 'Scanning...' : 'Preview Cleanup'}
</button>
<button
onClick={() => handleCleanup(false)}
disabled={cleaning || !previewDone}
className="px-3 py-1.5 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{cleaning ? 'Cleaning...' : 'Clean Up Now'}
</button>
</div>
)}
{cleanupResult && (
<div className={`p-2 rounded text-sm ${
cleanupResult.dryRun
? 'bg-accent-amber/10 border border-accent-amber/30 text-accent-amber'
: 'bg-status-online/10 border border-status-online/30 text-status-online'
}`}>
<div className="font-medium mb-1">
{cleanupResult.dryRun ? 'Preview — no files deleted' : 'Cleanup complete'}
</div>
<div>
{cleanupResult.deletedFiles} orphaned file{cleanupResult.deletedFiles !== 1 ? 's' : ''} ({formatBytes(cleanupResult.freedBytes)})
{cleanupResult.deletedAttachmentRecords > 0 && (
<>, {cleanupResult.deletedAttachmentRecords} stale upload record{cleanupResult.deletedAttachmentRecords !== 1 ? 's' : ''}</>
)}
</div>
{cleanupResult.errors.length > 0 && (
<div className="mt-1 text-txt-danger">
{cleanupResult.errors.length} error{cleanupResult.errors.length !== 1 ? 's' : ''}: {cleanupResult.errors[0]}
</div>
)}
</div>
)}
</div>
</div>
{/* Error / Refresh */}
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
<button
onClick={() => { setCleanupResult(null); setPreviewDone(false); fetchStats(); }}
className="text-sm text-accent-primary hover:underline"
>
Refresh Stats
</button>
</div>
);
}
@@ -142,7 +142,7 @@ export function StreamingPanel() {
const v = Number(e.target.value); const v = Number(e.target.value);
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v }); 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"
/> />
<span className="text-[12px] text-txt-tertiary">kbps</span> <span className="text-[12px] text-txt-tertiary">kbps</span>
</div> </div>
@@ -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<AdminUserListResponse | null>(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<ReturnType<typeof setTimeout>>();
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 (
<div className="space-y-4">
<div className="text-xs text-txt-tertiary">
View and manage user accounts on this instance.
</div>
{/* Search + Show Deleted */}
<div className="flex items-center gap-3">
<input
type="text"
value={query}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search users..."
className="input-search flex-1"
/>
<label className="flex items-center gap-2 text-sm text-txt-secondary cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => { setShowDeleted(e.target.checked); setPage(1); }}
className="w-3.5 h-3.5 rounded border-border-soft accent-accent-primary"
/>
Show deleted
</label>
</div>
{/* Error */}
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
{/* Temp password banner */}
{tempPassword && (
<div className="p-3 bg-status-online/10 border border-status-online/30 rounded-lg">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-txt-secondary">
Temporary password for <span className="font-medium text-txt-primary">{data?.users.find(u => u.id === tempPassword.userId)?.username ?? 'user'}</span>:
</div>
<button
onClick={() => setTempPassword(null)}
className="text-txt-tertiary hover:text-txt-secondary text-xs"
>
Dismiss
</button>
</div>
<div className="mt-1.5 flex items-center gap-2">
<code className="px-2 py-1 bg-black/30 rounded text-sm font-mono text-status-online select-all">
{tempPassword.password}
</code>
<button
onClick={() => navigator.clipboard.writeText(tempPassword.password)}
className="px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-xs rounded transition-colors"
>
Copy
</button>
</div>
<div className="text-xs text-txt-tertiary mt-1.5">
This password is shown once. The user has been disconnected and must log in again.
</div>
</div>
)}
{/* Loading */}
{loading && !data && (
<div className="text-sm text-txt-tertiary py-4">Loading users...</div>
)}
{/* User list */}
{data && (
<div className="space-y-1.5">
{data.users.length === 0 && (
<div className="text-sm text-txt-tertiary py-4 text-center">No users found</div>
)}
{data.users.map((user) => {
const isSelf = user.id === currentUser?.id;
const isFederated = !!user.homeInstance;
const isDeleted = user.isDeleted;
return (
<div key={user.id} className="flex items-center gap-3 rounded-lg bg-white/[0.02] p-3.5">
{/* Avatar */}
<div className={isDeleted ? 'opacity-50' : ''}>
<Avatar
src={user.avatar ? api.uploads.url(user.avatar) : null}
name={user.displayName || user.username}
size={32}
avatarColor={user.avatarColor as any}
/>
</div>
{/* Info */}
<div className={`flex-1 min-w-0 ${isDeleted ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2">
<span className={`text-sm font-medium text-txt-primary truncate ${isDeleted ? 'line-through' : ''}`}>
{user.username}
</span>
{user.displayName && !isDeleted && (
<span className="text-xs text-txt-tertiary truncate">{user.displayName}</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{user.isAdmin && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-amber/20 text-accent-amber">
Admin
</span>
)}
{isFederated && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-sky/20 text-accent-sky truncate max-w-[120px]">
{user.homeInstance}
</span>
)}
{isDeleted && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-rose/20 text-accent-rose">
Deleted
</span>
)}
<span className="text-[10px] text-txt-tertiary">
{formatDate(user.createdAt)}
</span>
</div>
</div>
{/* Actions */}
{!isDeleted && (
<div className="flex items-center gap-1 shrink-0">
{/* Toggle admin */}
<button
onClick={() => handleToggleAdmin(user)}
disabled={isFederated && !user.isAdmin}
title={user.isAdmin ? 'Demote from admin' : isFederated ? 'Federated users cannot be admin' : 'Promote to admin'}
className={`p-1.5 rounded transition-colors ${
user.isAdmin
? 'text-accent-amber hover:bg-accent-amber/10'
: isFederated
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</button>
{/* Reset password */}
<button
onClick={() => handleResetPassword(user)}
disabled={isFederated}
title={isFederated ? 'Federated users authenticate via home instance' : 'Reset password'}
className={`p-1.5 rounded transition-colors ${
isFederated
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</button>
{/* Delete user */}
<button
onClick={() => handleDeleteUser(user)}
disabled={isSelf}
title={isSelf ? 'Use account settings to delete your own account' : 'Delete user'}
className={`p-1.5 rounded transition-colors ${
isSelf
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-accent-rose hover:bg-accent-rose/10'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
)}
</div>
);
})}
</div>
)}
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-xs text-txt-tertiary">
Page {page} of {totalPages} ({data.total} user{data.total !== 1 ? 's' : ''})
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
{/* Confirm dialogs */}
<ConfirmDialog
isOpen={confirmAction?.type === 'demote'}
onClose={() => setConfirmAction(null)}
onConfirm={handleConfirm}
title="Demote Admin"
description={<>Remove admin privileges from <strong>{confirmAction?.user.username}</strong>? They will lose access to instance settings.</>}
confirmLabel="Demote"
variant="warning"
loading={actionLoading}
/>
<ConfirmDialog
isOpen={confirmAction?.type === 'delete'}
onClose={() => setConfirmAction(null)}
onConfirm={handleConfirm}
title="Delete User"
description={<>Permanently delete <strong>{confirmAction?.user.username}</strong>? This will remove them from all spaces, DMs, and friends lists. This cannot be undone.</>}
confirmLabel="Delete User"
variant="danger"
loading={actionLoading}
/>
</div>
);
}
@@ -498,7 +498,7 @@ export function AccountPanel() {
} }
}} }}
placeholder="#hex" 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} maxLength={7}
/> />
{accentColor && ( {accentColor && (
@@ -530,7 +530,7 @@ export function AccountPanel() {
}} }}
rows={3} rows={3}
placeholder="Tell the world about yourself..." 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} maxLength={190}
/> />
<span className="absolute bottom-2 right-2 text-[10px] text-txt-tertiary"> <span className="absolute bottom-2 right-2 text-[10px] text-txt-tertiary">
@@ -550,7 +550,7 @@ export function AccountPanel() {
<select <select
value={status} value={status}
onChange={(e) => setStatus(e.target.value as UserStatus)} onChange={(e) => setStatus(e.target.value as UserStatus)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none" className="input-standard w-full appearance-none"
> >
<option value="online">Online</option> <option value="online">Online</option>
<option value="idle">Idle</option> <option value="idle">Idle</option>
@@ -564,7 +564,7 @@ export function AccountPanel() {
type="text" type="text"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(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"
/> />
</div> </div>
@@ -574,7 +574,7 @@ export function AccountPanel() {
type="text" type="text"
value={customStatus} value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)} onChange={(e) => setCustomStatus(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"
placeholder="What are you up to?" placeholder="What are you up to?"
/> />
</div> </div>
@@ -584,7 +584,8 @@ export function AccountPanel() {
{/* ── Password ── */} {/* ── Password ── */}
<div> <div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Password</div> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Password</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3"> <form onSubmit={(e) => { e.preventDefault(); handleChangePassword(); }} className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
<input type="text" autoComplete="username" value={user.username} readOnly tabIndex={-1} className="sr-only" />
<div> <div>
<label className="block text-xs text-txt-secondary mb-1.5">Current Password</label> <label className="block text-xs text-txt-secondary mb-1.5">Current Password</label>
<div className="relative"> <div className="relative">
@@ -592,8 +593,9 @@ export function AccountPanel() {
type={showCurrentPassword ? 'text' : 'password'} type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} 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" placeholder="Enter current password"
autoComplete="current-password"
/> />
<button <button
type="button" type="button"
@@ -620,8 +622,9 @@ export function AccountPanel() {
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(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="Minimum 6 characters" placeholder="Minimum 6 characters"
autoComplete="new-password"
/> />
<button <button
type="button" type="button"
@@ -647,8 +650,9 @@ export function AccountPanel() {
type="password" type="password"
value={confirmNewPassword} value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)} onChange={(e) => setConfirmNewPassword(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-primary" className="input-standard w-full"
placeholder="Confirm new password" placeholder="Confirm new password"
autoComplete="new-password"
/> />
</div> </div>
@@ -674,13 +678,13 @@ export function AccountPanel() {
)} )}
<button <button
onClick={handleChangePassword} type="submit"
disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword} disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{passwordLoading ? 'Changing...' : 'Change Password'} {passwordLoading ? 'Changing...' : 'Change Password'}
</button> </button>
</div> </form>
</div> </div>
{/* ── Danger Zone ── */} {/* ── Danger Zone ── */}
@@ -2,8 +2,10 @@ import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore'; import { useSettingsStore } from '../../../stores/settingsStore';
import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel'; import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel';
import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel'; 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() { export function InstancePanel() {
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
@@ -33,11 +35,19 @@ export function InstancePanel() {
<button onClick={() => setSubTab('streaming')} className={pillClass('streaming')}> <button onClick={() => setSubTab('streaming')} className={pillClass('streaming')}>
Streaming Streaming
</button> </button>
<button onClick={() => setSubTab('storage')} className={pillClass('storage')}>
Storage
</button>
<button onClick={() => setSubTab('users')} className={pillClass('users')}>
Users
</button>
</div> </div>
{/* Content */} {/* Content */}
{subTab === 'general' && <GeneralPanel />} {subTab === 'general' && <GeneralPanel />}
{subTab === 'streaming' && <StreamingPanel />} {subTab === 'streaming' && <StreamingPanel />}
{subTab === 'storage' && <StoragePanel />}
{subTab === 'users' && <UsersPanel />}
</div> </div>
); );
} }
@@ -437,7 +437,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
type="text" type="text"
value={spaceName} value={spaceName}
onChange={(e) => setSpaceName(e.target.value)} 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} disabled={!canManageSpace}
/> />
</div> </div>
@@ -494,7 +494,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
value={transferSearch} value={transferSearch}
onChange={(e) => setTransferSearch(e.target.value)} onChange={(e) => setTransferSearch(e.target.value)}
placeholder="Search members..." 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 autoFocus
/> />
<div className="max-h-[160px] overflow-y-auto space-y-0.5"> <div className="max-h-[160px] overflow-y-auto space-y-0.5">
@@ -503,7 +503,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
) : ( ) : (
transferCandidates.map((member) => { transferCandidates.map((member) => {
const avatarUrl = member.user.avatar 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; : null;
return ( return (
<button <button
@@ -278,7 +278,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
type="text" type="text"
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setDraftName(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 text-sm" className="input-standard w-full"
/> />
</div> </div>
<div> <div>
@@ -312,7 +312,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
const v = e.target.value; const v = e.target.value;
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setDraftColor(v); 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} maxLength={7}
/> />
</div> </div>
+1 -1
View File
@@ -102,7 +102,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
> >
{src ? ( {src ? (
<img <img
src={(src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:')) src={(src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('/'))
? src : `/api/uploads/${src}`} ? src : `/api/uploads/${src}`}
alt={name} alt={name}
loading="lazy" loading="lazy"
@@ -68,7 +68,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
// Banner display // Banner display
const bannerSrc = user.banner const bannerSrc = user.banner
? (user.banner.startsWith('http') ? user.banner : userApi.uploads.url(user.banner)) ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
: null; : null;
const bannerFallback = user.accentColor const bannerFallback = user.accentColor
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40)) ? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
+27
View File
@@ -203,6 +203,33 @@
.glass-pill-mine:hover { .glass-pill-mine:hover {
background: rgba(134, 239, 172, 0.16); background: rgba(134, 239, 172, 0.16);
} }
/* ── Input Tiers ── */
.input-standard {
@apply bg-surface-input rounded px-3 py-2 text-sm text-txt-primary
placeholder:text-txt-tertiary outline-none
border border-white/[0.06] shadow-input
focus:ring-2 focus:ring-accent-primary focus:border-accent-primary/30
transition-colors;
}
.input-search {
@apply bg-surface-input rounded px-3 py-1.5 text-sm text-txt-primary
placeholder:text-txt-tertiary outline-none
border border-white/[0.06] shadow-input
focus:ring-1 focus:ring-accent-primary focus:border-accent-primary/30
transition-colors;
}
.input-embedded {
@apply bg-transparent text-txt-primary
placeholder:text-txt-tertiary/60 outline-none;
}
.input-danger {
@apply bg-surface-input rounded px-3 py-2 text-sm text-txt-primary
placeholder:text-txt-tertiary outline-none
border border-white/[0.06] shadow-input
focus:ring-2 focus:ring-accent-rose focus:border-accent-rose/30
transition-colors;
}
} }
/* Accessibility: fall back to solid surfaces when transparency is reduced */ /* Accessibility: fall back to solid surfaces when transparency is reduced */
+1
View File
@@ -68,6 +68,7 @@ export default {
'elevation-low': '0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05)', 'elevation-low': '0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05)',
'elevation-high': '0 8px 16px rgba(0, 0, 0, 0.24)', 'elevation-high': '0 8px 16px rgba(0, 0, 0, 0.24)',
'glass': '0 2px 8px rgba(0,0,0,0.25), 0 8px 24px rgba(0,0,0,0.15), inset 0 1px 0 var(--glass-highlight)', 'glass': '0 2px 8px rgba(0,0,0,0.25), 0 8px 24px rgba(0,0,0,0.15), inset 0 1px 0 var(--glass-highlight)',
'input': 'inset 0 1px 2px rgba(0, 0, 0, 0.25)',
}, },
fontFamily: { fontFamily: {
sans: ['DM Sans', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'], sans: ['DM Sans', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'],