feat: dedicated Instance Settings modal with admin controls
Move instance-level administration out of Space Settings into its own modal. Adds admin UI for instance name, registration toggle, and discovery toggle. Streaming limits panel relocated from SpaceSettings. - Add InstanceAdminSettings type and GET/PATCH /api/settings/instance - Add registration_open DB column (nullable, env var fallback) - Auth registration and instance info now check DB override - New InstanceSettings modal with General and Streaming tabs - Admin shield button in UserAreaPanel (visible to admins only) - Remove Streaming tab from SpaceSettings
This commit is contained in:
@@ -69,7 +69,8 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" },
|
||||
{ name: 'worker_id', type: 'INTEGER' },
|
||||
{ name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' }
|
||||
{ name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' },
|
||||
{ name: 'registration_open', type: 'INTEGER' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -192,6 +192,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
allowedFramerates: text('allowed_framerates').notNull().default('30,45,60'),
|
||||
maxResolution: integer('max_resolution').notNull().default(1080),
|
||||
maxFramerate: integer('max_framerate').notNull().default(60),
|
||||
registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -71,12 +71,17 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (!config.registrationOpen) {
|
||||
const db = getDb();
|
||||
|
||||
// Check registration: DB setting overrides env var if explicitly set by admin
|
||||
const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined
|
||||
? instanceRow.registrationOpen === 1
|
||||
: config.registrationOpen;
|
||||
if (!registrationOpen) {
|
||||
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
|
||||
|
||||
@@ -13,10 +13,15 @@ export async function instanceRoutes(app: FastifyInstance): Promise<void> {
|
||||
const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
const instanceName = settings?.instanceName ?? 'Backspace';
|
||||
|
||||
// DB setting overrides env var if explicitly set by admin
|
||||
const registrationOpen = settings?.registrationOpen !== null && settings?.registrationOpen !== undefined
|
||||
? settings.registrationOpen === 1
|
||||
: config.registrationOpen;
|
||||
|
||||
const response: InstanceInfoResponse = {
|
||||
name: instanceName,
|
||||
version: BACKSPACE_VERSION,
|
||||
registrationOpen: config.registrationOpen,
|
||||
registrationOpen,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
import { config } from '../config.js';
|
||||
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080];
|
||||
const VALID_FRAMERATES = [30, 45, 60];
|
||||
@@ -126,4 +127,70 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(200).send(rowToLimits(updatedRow));
|
||||
});
|
||||
|
||||
// GET /api/settings/instance — admin only, returns instance admin settings
|
||||
app.get('/api/settings/instance', { preHandler: authenticate }, 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 });
|
||||
}
|
||||
|
||||
const response: InstanceAdminSettings = {
|
||||
instanceName: row.instanceName ?? 'Backspace',
|
||||
registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen,
|
||||
discoveryEnabled: row.discoveryEnabled === 1,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
});
|
||||
|
||||
// PATCH /api/settings/instance — admin only, updates instance admin settings
|
||||
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: authenticate }, 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() };
|
||||
|
||||
if (body.instanceName !== undefined) {
|
||||
if (typeof body.instanceName !== 'string' || body.instanceName.trim().length === 0 || body.instanceName.trim().length > 32) {
|
||||
return reply.code(400).send({ error: 'Instance name must be 1-32 characters', statusCode: 400 });
|
||||
}
|
||||
updateData.instanceName = body.instanceName.trim();
|
||||
}
|
||||
|
||||
if (body.registrationOpen !== undefined) {
|
||||
updateData.registrationOpen = body.registrationOpen ? 1 : 0;
|
||||
}
|
||||
|
||||
if (body.discoveryEnabled !== undefined) {
|
||||
updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0;
|
||||
}
|
||||
|
||||
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
|
||||
|
||||
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!updatedRow) {
|
||||
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
|
||||
}
|
||||
|
||||
const response: InstanceAdminSettings = {
|
||||
instanceName: updatedRow.instanceName ?? 'Backspace',
|
||||
registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen,
|
||||
discoveryEnabled: updatedRow.discoveryEnabled === 1,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user