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:
@@ -36,6 +36,19 @@ Every surface in Backspace falls into one of these tiers:
|
||||
|
||||
**Modal backdrops** use `bg-black/50` — light enough for the glass card's blur to show through.
|
||||
|
||||
### Input Tiers
|
||||
|
||||
Every text input, textarea, and select uses one of these CSS classes (defined in `globals.css`):
|
||||
|
||||
| Tier | Class | When to Use | Focus |
|
||||
|------|-------|-------------|-------|
|
||||
| Standard | `.input-standard` | Form fields in modals, settings, auth pages | `ring-2` primary |
|
||||
| Search | `.input-search` | Search bars, filter inputs, compact lookups | `ring-1` primary |
|
||||
| Embedded | `.input-embedded` | Inside glass containers (chat input, search popover, DM search) | none |
|
||||
| Danger | `.input-danger` | Destructive confirmations (delete account) | `ring-2` rose |
|
||||
|
||||
**Rule:** No resting border — the sunken `surface-input` background provides differentiation. Override padding/size with utility classes when needed (e.g. `input-standard w-full py-2.5` for taller auth inputs).
|
||||
|
||||
## MISSION
|
||||
|
||||
Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed across multiple instances with federation support. Every change must uphold the same standard: no stubs, no TODOs, no shortcuts. A user must always be able to `docker compose up` and have a fully working chat platform.
|
||||
@@ -112,6 +125,7 @@ Backspace/
|
||||
│ │ │ ├── seed.ts
|
||||
│ │ │ └── migrate.ts
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin.ts
|
||||
│ │ │ ├── auth.ts
|
||||
│ │ │ ├── users.ts
|
||||
│ │ │ ├── spaces.ts
|
||||
@@ -134,7 +148,8 @@ Backspace/
|
||||
│ │ ├── snowflake.ts
|
||||
│ │ ├── permissions.ts
|
||||
│ │ ├── sanitize.ts
|
||||
│ │ └── fileCleanup.ts
|
||||
│ │ ├── fileCleanup.ts
|
||||
│ │ └── storageJanitor.ts
|
||||
│ ├── web/
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json
|
||||
@@ -252,7 +267,8 @@ Backspace/
|
||||
│ │ │ │ │ └── BansPanel.tsx
|
||||
│ │ │ │ └── instanceSettingsPanels/
|
||||
│ │ │ │ ├── GeneralPanel.tsx
|
||||
│ │ │ │ └── StreamingPanel.tsx
|
||||
│ │ │ │ ├── StreamingPanel.tsx
|
||||
│ │ │ │ └── StoragePanel.tsx
|
||||
│ │ │ └── ui/
|
||||
│ │ │ ├── Avatar.tsx
|
||||
│ │ │ ├── Modal.tsx
|
||||
@@ -650,6 +666,15 @@ PATCH /api/settings/streaming (auth, admin) { maxBitrateKbps?, ... } → {
|
||||
GET /api/settings/instance (auth, admin) → { instanceName, registrationOpen, discoveryEnabled }
|
||||
PATCH /api/settings/instance (auth, admin) { instanceName?, registrationOpen?, discoveryEnabled? } → { settings }
|
||||
|
||||
# Admin
|
||||
GET /api/admin/storage/stats (auth, admin) → StorageStats
|
||||
GET /api/admin/storage/orphans (auth, admin) → { orphans: OrphanedFile[] }
|
||||
POST /api/admin/storage/cleanup (auth, admin) { dryRun?: boolean } → CleanupResult
|
||||
GET /api/admin/users (auth, admin) ?q=&page=&pageSize=&showDeleted= → AdminUserListResponse
|
||||
PATCH /api/admin/users/:id/role (auth, admin) { isAdmin: boolean } → AdminUser
|
||||
POST /api/admin/users/:id/reset-password (auth, admin) → { temporaryPassword }
|
||||
DELETE /api/admin/users/:id (auth, admin) → { success }
|
||||
|
||||
# Utilities
|
||||
GET /api/utils/metadata (auth) ?url= → { title?, description?, image?, siteName? }
|
||||
GET /api/health (public) → { status: 'ok', timestamp }
|
||||
|
||||
@@ -22,6 +22,7 @@ import { utilRoutes } from './routes/utils.js';
|
||||
import { instanceRoutes } from './routes/instance.js';
|
||||
import { exploreRoutes } from './routes/explore.js';
|
||||
import { searchRoutes } from './routes/search.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { registerWebSocket } from './ws/handler.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -88,6 +89,7 @@ async function main(): Promise<void> {
|
||||
await app.register(instanceRoutes);
|
||||
await app.register(exploreRoutes);
|
||||
await app.register(searchRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(registerWebSocket);
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { authenticate, requireAdmin } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
|
||||
|
||||
@@ -33,15 +33,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
// PATCH /api/settings/streaming — admin only
|
||||
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => {
|
||||
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// Verify caller is an instance admin
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can modify streaming settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
|
||||
|
||||
@@ -129,14 +123,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
// GET /api/settings/instance — admin only, returns instance admin settings
|
||||
app.get('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
|
||||
app.get('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can view instance settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!row) {
|
||||
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
|
||||
@@ -152,14 +141,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
// PATCH /api/settings/instance — admin only, updates instance admin settings
|
||||
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
|
||||
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can modify instance settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, or, and, inArray } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
import { deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -125,123 +125,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const uid = request.userId;
|
||||
|
||||
// Collect file references before the transaction (avatar, banner)
|
||||
const filesToDelete: string[] = [];
|
||||
if (user.avatar) filesToDelete.push(user.avatar);
|
||||
if (user.banner) filesToDelete.push(user.banner);
|
||||
|
||||
// Find group DMs this user owns so we can transfer ownership
|
||||
const ownedGroupDms = db.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.ownerId, uid))
|
||||
.all();
|
||||
|
||||
// Run all cleanup in a single transaction
|
||||
db.transaction((tx) => {
|
||||
// Remove from spaces, roles, friends, DMs, read states, reactions, folders, bans, join requests, voice restrictions, channel overrides
|
||||
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run();
|
||||
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
|
||||
tx.delete(schema.friends).where(or(eq(schema.friends.userId, uid), eq(schema.friends.friendId, uid))).run();
|
||||
tx.delete(schema.friendRequests).where(or(eq(schema.friendRequests.fromId, uid), eq(schema.friendRequests.toId, uid))).run();
|
||||
tx.delete(schema.dmMembers).where(eq(schema.dmMembers.userId, uid)).run();
|
||||
tx.delete(schema.readStates).where(eq(schema.readStates.userId, uid)).run();
|
||||
tx.delete(schema.reactions).where(eq(schema.reactions.userId, uid)).run();
|
||||
tx.delete(schema.dmReactions).where(eq(schema.dmReactions.userId, uid)).run();
|
||||
tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run();
|
||||
|
||||
// Conditional deletes for tables that may reference userId
|
||||
try { tx.delete(schema.bans).where(eq(schema.bans.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
|
||||
// Nullify moderator references pointing to this user
|
||||
try {
|
||||
tx.update(schema.bans).set({ bannedBy: null }).where(eq(schema.bans.bannedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.voiceRestrictions).set({ moderatorId: null }).where(eq(schema.voiceRestrictions.moderatorId, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.joinRequests).set({ decidedBy: null }).where(eq(schema.joinRequests.decidedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
|
||||
// Remove member-type channel overrides for this user
|
||||
tx.delete(schema.channelOverrides).where(
|
||||
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
|
||||
).run();
|
||||
|
||||
// Transfer ownership of group DMs to the next remaining member
|
||||
for (const { id: dmId } of ownedGroupDms) {
|
||||
const nextMember = tx.select({ userId: schema.dmMembers.userId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmId))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (nextMember) {
|
||||
tx.update(schema.dmChannels)
|
||||
.set({ ownerId: nextMember.userId })
|
||||
.where(eq(schema.dmChannels.id, dmId))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned DM channels (zero members after our removal)
|
||||
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.all()
|
||||
.filter(dc => {
|
||||
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dc.id))
|
||||
.all()
|
||||
.length;
|
||||
return memberCount === 0;
|
||||
})
|
||||
.map(dc => dc.id);
|
||||
|
||||
for (const dmId of orphanedDmIds) {
|
||||
// Collect message IDs for this orphaned DM channel
|
||||
const msgIds = tx.select({ id: schema.dmMessages.id })
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, dmId))
|
||||
.all()
|
||||
.map(m => m.id);
|
||||
|
||||
if (msgIds.length > 0) {
|
||||
// Collect attachment filenames for cleanup after tx
|
||||
const dmAttachments = tx.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
for (const att of dmAttachments) filesToDelete.push(att.filename);
|
||||
|
||||
// Delete attachments + reactions for all messages in this DM channel
|
||||
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||
}
|
||||
// Delete the DM channel (cascades to dm_messages)
|
||||
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run();
|
||||
}
|
||||
|
||||
// Tombstone user row — rename username to free it for reuse
|
||||
tx.update(schema.users).set({
|
||||
username: `!deleted:${uid}`,
|
||||
passwordHash: crypto.randomBytes(32).toString('hex'), // unusable random string
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
banner: null,
|
||||
bio: null,
|
||||
customStatus: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
replicatedInstances: '[]',
|
||||
isDeleted: 1,
|
||||
status: 'offline',
|
||||
isAdmin: 0,
|
||||
}).where(eq(schema.users.id, uid)).run();
|
||||
});
|
||||
// Tombstone the account (transaction handles all DB cleanup)
|
||||
const filesToDelete = tombstoneUser(request.userId);
|
||||
|
||||
// Clean up files from disk after transaction commits
|
||||
for (const filename of filesToDelete) {
|
||||
@@ -569,6 +454,16 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
.set({ name: folder.name, color: folder.color })
|
||||
.where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
// Folder from a remote instance — create it locally with the original ID
|
||||
tx.insert(schema.spaceFolders).values({
|
||||
id: key,
|
||||
userId,
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
position: 0,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
// Clear and re-insert folder members with position
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
@@ -50,6 +52,17 @@ export async function authenticate(
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<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' {
|
||||
interface FastifyRequest {
|
||||
userId: string;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -561,3 +561,63 @@ export interface DeleteAccountRequest {
|
||||
password: string;
|
||||
username: string; // Must match — confirmation safeguard
|
||||
}
|
||||
|
||||
// ─── Storage Management Types ─────────────────────────────────────────────
|
||||
|
||||
export interface StorageBreakdown {
|
||||
type: string; // 'image' | 'video' | 'audio' | 'document' | 'other'
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
referencedFiles: number;
|
||||
referencedSize: number;
|
||||
orphanedFiles: number;
|
||||
orphanedSize: number;
|
||||
unlinkedAttachments: number;
|
||||
unlinkedSize: number;
|
||||
breakdown: StorageBreakdown[];
|
||||
}
|
||||
|
||||
export interface OrphanedFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
dryRun: boolean;
|
||||
deletedFiles: number;
|
||||
freedBytes: number;
|
||||
deletedAttachmentRecords: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ─── Admin User Management Types ──────────────────────────────────────────
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
avatarColor: string | null;
|
||||
status: string;
|
||||
isAdmin: boolean;
|
||||
isDeleted: boolean;
|
||||
homeInstance: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AdminResetPasswordResponse {
|
||||
temporaryPassword: string;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ import type {
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
DeleteAccountRequest,
|
||||
StorageStats,
|
||||
OrphanedFile,
|
||||
CleanupResult,
|
||||
AdminUserListResponse,
|
||||
AdminUser,
|
||||
AdminResetPasswordResponse,
|
||||
ExploreSpace,
|
||||
JoinRequest,
|
||||
Role,
|
||||
@@ -181,6 +187,16 @@ export class BackspaceApiClient {
|
||||
myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>;
|
||||
};
|
||||
|
||||
readonly admin: {
|
||||
storageStats: () => Promise<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) {
|
||||
async function request<T>(
|
||||
method: string,
|
||||
@@ -487,6 +503,26 @@ export class BackspaceApiClient {
|
||||
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}`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ export function JoinPage() {
|
||||
value={otherDomain}
|
||||
onChange={(e) => setOtherDomain(e.target.value)}
|
||||
placeholder="e.g. my-instance.com"
|
||||
className="flex-1 px-3 py-2.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard flex-1 py-2.5"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
@@ -406,6 +406,7 @@ export function JoinPage() {
|
||||
{/* Phase: connect — password prompt for federation */}
|
||||
{phase === 'connect' && (
|
||||
<form onSubmit={handleConnect}>
|
||||
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
|
||||
{/* Identity card */}
|
||||
<div className="flex items-center gap-3 bg-surface-input rounded-lg p-3 mb-3">
|
||||
<Avatar
|
||||
@@ -431,9 +432,10 @@ export function JoinPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<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.
|
||||
@@ -477,8 +479,9 @@ export function JoinPage() {
|
||||
value={fallbackUsername}
|
||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||
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}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -488,9 +491,10 @@ export function JoinPage() {
|
||||
value={fallbackPassword}
|
||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||
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}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function LoginPage() {
|
||||
type="text"
|
||||
value={username}
|
||||
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
|
||||
autoComplete="username"
|
||||
/>
|
||||
@@ -105,7 +105,7 @@ export function LoginPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -257,7 +257,7 @@ export function RegisterPage() {
|
||||
type="text"
|
||||
value={username}
|
||||
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
|
||||
autoComplete="username"
|
||||
/>
|
||||
@@ -296,7 +296,7 @@ export function RegisterPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -309,7 +309,7 @@ export function RegisterPage() {
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -396,7 +396,7 @@ export function RegisterPage() {
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function ExplorePage() {
|
||||
placeholder="Search spaces..."
|
||||
value={searchQuery}
|
||||
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 && (
|
||||
<button
|
||||
@@ -230,10 +230,10 @@ function SpaceCard({
|
||||
: null;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
// 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))}
|
||||
placeholder="Why do you want to join? (optional)"
|
||||
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">
|
||||
<button
|
||||
|
||||
@@ -268,7 +268,7 @@ function AddFriendTab({
|
||||
placeholder="You can add a friend with their username"
|
||||
value={addUsername}
|
||||
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
|
||||
type="submit"
|
||||
@@ -301,7 +301,7 @@ function AddFriendTab({
|
||||
placeholder="Search people..."
|
||||
value={discoverQuery}
|
||||
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 && (
|
||||
<button
|
||||
@@ -371,10 +371,10 @@ function UserDiscoverCard({
|
||||
: null;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
const handleSendRequest = async () => {
|
||||
|
||||
@@ -229,7 +229,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
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}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -262,9 +262,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
<div className="mt-1 grid gap-2">
|
||||
{message.attachments.map((att) => {
|
||||
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
|
||||
? (att.thumbnailFilename.startsWith('http') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`)
|
||||
? (att.thumbnailFilename.startsWith('http') || att.thumbnailFilename.startsWith('/') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`)
|
||||
: null;
|
||||
if (isImage) {
|
||||
return (
|
||||
|
||||
@@ -349,7 +349,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||
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}
|
||||
|
||||
/>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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 && (
|
||||
<button
|
||||
@@ -210,7 +210,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
value={fromFilter}
|
||||
onChange={(e) => setFromFilter(e.target.value)}
|
||||
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>
|
||||
@@ -218,7 +218,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
<select
|
||||
value={hasFilter}
|
||||
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="file">File</option>
|
||||
@@ -232,7 +232,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
type="date"
|
||||
value={beforeFilter}
|
||||
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>
|
||||
@@ -241,7 +241,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
type="date"
|
||||
value={afterFilter}
|
||||
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>
|
||||
|
||||
@@ -370,7 +370,7 @@ export function DmSearchBar() {
|
||||
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
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>
|
||||
) : (
|
||||
|
||||
@@ -126,7 +126,7 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
|
||||
)
|
||||
) : icon ? (
|
||||
<img
|
||||
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
|
||||
src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -200,7 +200,7 @@ function MiniSpaceIcon({ space }: { space: TaggedSpace }) {
|
||||
if (icon) {
|
||||
return (
|
||||
<img
|
||||
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
|
||||
src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover rounded-[3px]"
|
||||
/>
|
||||
@@ -482,7 +482,7 @@ function FolderFlyout({
|
||||
{isRenaming ? (
|
||||
<input
|
||||
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 || ''}
|
||||
onBlur={(e) => onRename(e.currentTarget.value)}
|
||||
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}>
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
|
||||
src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -97,7 +97,7 @@ export function AddDmMemberModal() {
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -638,7 +638,7 @@ function PermissionsTab({
|
||||
value={memberSearch}
|
||||
onChange={(e) => setMemberSearch(e.target.value)}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && url.trim() && handleProbe()}
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
@@ -138,7 +138,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
</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>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">
|
||||
Enter your password to connect to {new URL(probeResult.origin).host}
|
||||
@@ -147,24 +148,24 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()}
|
||||
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}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleFallbackLogin(); }} className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||
<input
|
||||
@@ -207,8 +208,9 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
value={fallbackUsername}
|
||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||
placeholder="Your username on this instance"
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -217,21 +219,21 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
type="password"
|
||||
value={fallbackPassword}
|
||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
|
||||
placeholder="Password on the remote instance"
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFallbackLogin}
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login & Connect'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -345,26 +347,28 @@ function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').Conn
|
||||
|
||||
{/* Inline re-authentication prompt */}
|
||||
{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">
|
||||
<input
|
||||
type="password"
|
||||
value={reauthPassword}
|
||||
onChange={(e) => setReauthPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !reauthLoading && reauthPassword && handleReauth()}
|
||||
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}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
onClick={handleReauth}
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{reauthLoading ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowReauth(false); setReauthPassword(''); setReauthError(''); }}
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function CreateCategoryModal() {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
|
||||
className="input-standard w-full"
|
||||
placeholder="new-category"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function CreateChannelModal() {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
|
||||
className="input-standard w-full"
|
||||
placeholder="new-channel"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -129,7 +129,7 @@ export function CreateChannelModal() {
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
|
||||
className="input-standard w-full"
|
||||
placeholder="What's this channel about?"
|
||||
/>
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@ export function CreateChannelModal() {
|
||||
<select
|
||||
value={categoryId}
|
||||
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>
|
||||
{[...categories].sort((a, b) => a.position - b.position).map((cat) => (
|
||||
|
||||
@@ -215,7 +215,7 @@ export function CreateSpaceModal() {
|
||||
type="text"
|
||||
value={name}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -263,7 +263,7 @@ export function CreateSpaceModal() {
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
|
||||
placeholder="A short description for your space..."
|
||||
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>
|
||||
|
||||
@@ -247,7 +247,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
<select
|
||||
value={space.transferTo}
|
||||
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>
|
||||
{space.members.map(m => (
|
||||
@@ -282,7 +282,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
|
||||
{/* Step 2: Confirmation */}
|
||||
{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">
|
||||
<p className="text-sm text-txt-danger font-medium">This action is permanent and cannot be undone.</p>
|
||||
</div>
|
||||
@@ -295,7 +295,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
type="text"
|
||||
value={confirmUsername}
|
||||
onChange={(e) => setConfirmUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-rose"
|
||||
className="input-danger w-full"
|
||||
placeholder={user.username}
|
||||
/>
|
||||
</div>
|
||||
@@ -306,8 +306,9 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-rose"
|
||||
className="input-danger w-full"
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -323,14 +324,14 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete My Account'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 3: Federation Progress */}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function InviteModal() {
|
||||
type="text"
|
||||
value={isLoading ? 'Generating...' : inviteUrl}
|
||||
readOnly
|
||||
className="invite-code-input flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary outline-none font-mono text-xs"
|
||||
className="input-standard invite-code-input flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function JoinSpaceModal() {
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -177,6 +177,7 @@ export function JoinSpaceModal() {
|
||||
{/* Phase: connect — password prompt to connect to remote instance */}
|
||||
{phase === 'connect' && (
|
||||
<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">
|
||||
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
|
||||
</p>
|
||||
@@ -190,9 +191,10 @@ export function JoinSpaceModal() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<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.
|
||||
@@ -247,8 +249,9 @@ export function JoinSpaceModal() {
|
||||
value={fallbackUsername}
|
||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||
placeholder="Your username on this instance"
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -258,9 +261,10 @@ export function JoinSpaceModal() {
|
||||
value={fallbackPassword}
|
||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||
placeholder="Password on the remote instance"
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function NewDmModal() {
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
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 && (
|
||||
|
||||
@@ -116,7 +116,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
|
||||
placeholder="A short description for the Explore page..."
|
||||
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>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50"
|
||||
className="input-search w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
) : (
|
||||
filteredMembers.map((member) => {
|
||||
const avatarUrl = member.user.avatar
|
||||
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
|
||||
? (member.user.avatar.startsWith('http') || member.user.avatar.startsWith('/') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -83,7 +83,7 @@ export function UserSettingsModal() {
|
||||
</div>
|
||||
|
||||
{/* 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 === 'voice' && <VoicePanel />}
|
||||
{tab === 'privacy' && <PrivacyPanel />}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function GeneralPanel() {
|
||||
value={draft.instanceName}
|
||||
onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })}
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
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>
|
||||
</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"
|
||||
className="w-24 px-2 py-1.5 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary font-mono"
|
||||
className="input-standard w-24 px-2 py-1.5 text-xs font-mono"
|
||||
maxLength={7}
|
||||
/>
|
||||
{accentColor && (
|
||||
@@ -530,7 +530,7 @@ export function AccountPanel() {
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="Tell the world about yourself..."
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary resize-none"
|
||||
className="input-standard w-full resize-none"
|
||||
maxLength={190}
|
||||
/>
|
||||
<span className="absolute bottom-2 right-2 text-[10px] text-txt-tertiary">
|
||||
@@ -550,7 +550,7 @@ export function AccountPanel() {
|
||||
<select
|
||||
value={status}
|
||||
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="idle">Idle</option>
|
||||
@@ -564,7 +564,7 @@ export function AccountPanel() {
|
||||
type="text"
|
||||
value={displayName}
|
||||
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>
|
||||
|
||||
@@ -574,7 +574,7 @@ export function AccountPanel() {
|
||||
type="text"
|
||||
value={customStatus}
|
||||
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?"
|
||||
/>
|
||||
</div>
|
||||
@@ -584,7 +584,8 @@ export function AccountPanel() {
|
||||
{/* ── 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>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">Current Password</label>
|
||||
<div className="relative">
|
||||
@@ -592,8 +593,9 @@ export function AccountPanel() {
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 pr-10 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard w-full pr-10"
|
||||
placeholder="Enter current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -620,8 +622,9 @@ export function AccountPanel() {
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
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"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -647,8 +650,9 @@ export function AccountPanel() {
|
||||
type="password"
|
||||
value={confirmNewPassword}
|
||||
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"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -674,13 +678,13 @@ export function AccountPanel() {
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Danger Zone ── */}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useState, useEffect } from 'react';
|
||||
import { useSettingsStore } from '../../../stores/settingsStore';
|
||||
import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel';
|
||||
import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel';
|
||||
import { StoragePanel } from '../instanceSettingsPanels/StoragePanel';
|
||||
import { UsersPanel } from '../instanceSettingsPanels/UsersPanel';
|
||||
|
||||
type SubTab = 'general' | 'streaming';
|
||||
type SubTab = 'general' | 'streaming' | 'storage' | 'users';
|
||||
|
||||
export function InstancePanel() {
|
||||
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
|
||||
@@ -33,11 +35,19 @@ export function InstancePanel() {
|
||||
<button onClick={() => setSubTab('streaming')} className={pillClass('streaming')}>
|
||||
Streaming
|
||||
</button>
|
||||
<button onClick={() => setSubTab('storage')} className={pillClass('storage')}>
|
||||
Storage
|
||||
</button>
|
||||
<button onClick={() => setSubTab('users')} className={pillClass('users')}>
|
||||
Users
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{subTab === 'general' && <GeneralPanel />}
|
||||
{subTab === 'streaming' && <StreamingPanel />}
|
||||
{subTab === 'storage' && <StoragePanel />}
|
||||
{subTab === 'users' && <UsersPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
type="text"
|
||||
value={spaceName}
|
||||
onChange={(e) => setSpaceName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
className="input-standard w-full"
|
||||
disabled={!canManageSpace}
|
||||
/>
|
||||
</div>
|
||||
@@ -494,7 +494,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
value={transferSearch}
|
||||
onChange={(e) => setTransferSearch(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50"
|
||||
className="input-search w-full"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-[160px] overflow-y-auto space-y-0.5">
|
||||
@@ -503,7 +503,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
) : (
|
||||
transferCandidates.map((member) => {
|
||||
const avatarUrl = member.user.avatar
|
||||
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
|
||||
? (member.user.avatar.startsWith('http') || member.user.avatar.startsWith('/') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -278,7 +278,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
type="text"
|
||||
value={draftName}
|
||||
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>
|
||||
@@ -312,7 +312,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
const v = e.target.value;
|
||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setDraftColor(v);
|
||||
}}
|
||||
className="w-20 px-2 py-1 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary font-mono"
|
||||
className="input-standard w-20 px-2 py-1 text-xs font-mono"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
||||
>
|
||||
{src ? (
|
||||
<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}`}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
|
||||
@@ -68,7 +68,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
||||
|
||||
// Banner display
|
||||
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;
|
||||
const bannerFallback = user.accentColor
|
||||
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
|
||||
|
||||
@@ -203,6 +203,33 @@
|
||||
.glass-pill-mine:hover {
|
||||
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 */
|
||||
|
||||
@@ -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-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)',
|
||||
'input': 'inset 0 1px 2px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['DM Sans', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'],
|
||||
|
||||
Reference in New Issue
Block a user