Implement Owner Panel features and maintenance mode handling

- Introduced the Owner Panel with new routes and UI components for managing guilds, user blacklists, feature flags, and bot presence.
- Added maintenance mode functionality to prevent non-owner users from executing commands during maintenance periods.
- Enhanced localization with new keys for Owner Panel features in both English and German.
- Updated job processing to include a presence refresh job, ensuring the bot's status is regularly updated.
- Improved environment configuration to support owner user IDs for access control.
This commit is contained in:
smueller
2026-07-22 16:10:30 +02:00
parent bcbfa07697
commit 8a8aefb4fb
42 changed files with 2385 additions and 50 deletions

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { toApiErrorResponse } from '@/lib/auth';
import { listOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
export async function GET(request: Request) {
try {
await requireOwner('VIEWER');
const { searchParams } = new URL(request.url);
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100);
const offset = Math.max(Number(searchParams.get('offset') ?? 0), 0);
const entries = await listOwnerAudit(limit, offset);
return NextResponse.json({ entries });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { ChangelogEntrySchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const entries = await prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } });
return NextResponse.json({
entries: entries.map((entry) => ({
...entry,
publishedAt: entry.publishedAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
updatedAt: entry.updatedAt.toISOString()
}))
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = ChangelogEntrySchema.parse(await request.json());
const entry = await prisma.changelogEntry.create({
data: {
version: body.version,
title: body.title,
body: body.body,
publishedAt: body.publishedAt ? new Date(body.publishedAt) : undefined
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'changelog.create',
targetType: 'ChangelogEntry',
targetId: entry.id,
after: entry
});
return NextResponse.json(entry);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('ADMIN');
const id = new URL(request.url).searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'id required' }, { status: 400 });
}
const before = await prisma.changelogEntry.findUnique({ where: { id } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.changelogEntry.delete({ where: { id } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'changelog.delete',
targetType: 'ChangelogEntry',
targetId: id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server';
import { FeatureFlagUpsertSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const flags = await prisma.featureFlag.findMany({ orderBy: { key: 'asc' } });
return NextResponse.json({ flags });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = FeatureFlagUpsertSchema.parse(await request.json());
const before = await prisma.featureFlag.findUnique({ where: { key: body.key } });
const flag = await prisma.featureFlag.upsert({
where: { key: body.key },
create: {
key: body.key,
enabled: body.enabled,
rolloutPercent: body.rolloutPercent,
whitelistGuildIds: body.whitelistGuildIds
},
update: {
enabled: body.enabled,
rolloutPercent: body.rolloutPercent,
whitelistGuildIds: body.whitelistGuildIds
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: before ? 'flags.update' : 'flags.create',
targetType: 'FeatureFlag',
targetId: flag.id,
before,
after: flag
});
return NextResponse.json(flag);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('ADMIN');
const key = new URL(request.url).searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'key required' }, { status: 400 });
}
const before = await prisma.featureFlag.findUnique({ where: { key } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.featureFlag.delete({ where: { key } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'flags.delete',
targetType: 'FeatureFlag',
targetId: before.id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from 'next/server';
import { GuildBlacklistSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { env } from '@/lib/env';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
await requireOwner('VIEWER');
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
const guilds = await prisma.guild.findMany({
where: q ? { id: { contains: q } } : undefined,
include: { settings: true },
orderBy: { createdAt: 'desc' },
take: 100
});
const blacklist = await prisma.guildBlacklist.findMany();
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
return NextResponse.json({
guilds: guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
})),
blacklist
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
}
if (body.action === 'leave') {
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
method: 'DELETE',
headers: { Authorization: `Bot ${env.BOT_TOKEN}` }
});
if (!response.ok && response.status !== 404) {
return NextResponse.json({ error: `Discord leave failed (${response.status})` }, { status: 502 });
}
await prisma.guild.delete({ where: { id: body.guildId } }).catch(() => undefined);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.leave',
targetType: 'Guild',
targetId: body.guildId
});
return NextResponse.json({ ok: true });
}
if (body.action === 'blacklist') {
const parsed = GuildBlacklistSchema.parse({
guildId: body.guildId,
reason: body.reason ?? null
});
const row = await prisma.guildBlacklist.upsert({
where: { guildId: parsed.guildId },
create: {
guildId: parsed.guildId,
reason: parsed.reason ?? null,
createdById: session.user.id
},
update: { reason: parsed.reason ?? null }
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.blacklist',
targetType: 'GuildBlacklist',
targetId: row.id,
after: row
});
return NextResponse.json(row);
}
if (body.action === 'unblacklist') {
const before = await prisma.guildBlacklist.findUnique({ where: { guildId: body.guildId } });
if (before) {
await prisma.guildBlacklist.delete({ where: { guildId: body.guildId } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.unblacklist',
targetType: 'GuildBlacklist',
targetId: before.id,
before
});
}
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import { toApiErrorResponse } from '@/lib/auth';
import { getAllQueueStats, retryFailedJobs } from '@/lib/owner-jobs';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('SUPPORT');
const [queues, migrations] = await Promise.all([
getAllQueueStats(),
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
`
]);
return NextResponse.json({
queues,
migrations: migrations.map((row) => ({
name: row.migration_name,
finishedAt: row.finished_at?.toISOString() ?? null
}))
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = (await request.json()) as { queueName?: string };
if (!body.queueName) {
return NextResponse.json({ error: 'queueName required' }, { status: 400 });
}
const retried = await retryFailedJobs(body.queueName);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'jobs.retryFailed',
targetType: 'Queue',
targetId: body.queueName,
after: { retried }
});
return NextResponse.json({ retried });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { toApiErrorResponse } from '@/lib/auth';
import { listOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
import { getPresenceConfig } from '@/lib/owner-presence';
export async function GET() {
try {
await requireOwner('VIEWER');
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] =
await Promise.all([
prisma.guild.count(),
prisma.globalUserBlacklist.count(),
prisma.guildBlacklist.count(),
getPresenceConfig(),
listOwnerAudit(10),
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
]);
return NextResponse.json({
guildCount,
blacklistUsers,
blacklistGuilds,
uptimeSeconds: Math.floor(process.uptime()),
maintenanceMode: presence.maintenanceMode,
recentAudit: audit,
changelog: changelog.map((entry) => ({
id: entry.id,
version: entry.version,
title: entry.title,
publishedAt: entry.publishedAt.toISOString()
}))
});
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { BotPresenceConfigPatchSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { getPresenceConfig, updatePresenceConfig } from '@/lib/owner-presence';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
return NextResponse.json(await getPresenceConfig());
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = BotPresenceConfigPatchSchema.parse(await request.json());
const before = await getPresenceConfig();
const after = await updatePresenceConfig(body);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'presence.update',
targetType: 'BotPresenceConfig',
targetId: 'singleton',
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { OwnerTeamUpsertSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { env } from '@/lib/env';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const members = await prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } });
return NextResponse.json({
bootstrapOwnerIds: env.OWNER_USER_IDS ?? [],
members
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireOwner('OWNER');
const body = OwnerTeamUpsertSchema.parse(await request.json());
const before = await prisma.ownerTeamMember.findUnique({ where: { userId: body.userId } });
const member = await prisma.ownerTeamMember.upsert({
where: { userId: body.userId },
create: {
userId: body.userId,
role: body.role,
note: body.note ?? null
},
update: {
role: body.role,
note: body.note ?? null
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: before ? 'team.update' : 'team.create',
targetType: 'OwnerTeamMember',
targetId: member.id,
before,
after: member
});
return NextResponse.json(member);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('OWNER');
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const before = await prisma.ownerTeamMember.findUnique({ where: { userId } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.ownerTeamMember.delete({ where: { userId } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'team.delete',
targetType: 'OwnerTeamMember',
targetId: before.id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { GlobalUserBlacklistSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const users = await prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } });
return NextResponse.json({ users });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireOwner('SUPPORT');
const body = GlobalUserBlacklistSchema.parse(await request.json());
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId: body.userId } });
const row = await prisma.globalUserBlacklist.upsert({
where: { userId: body.userId },
create: {
userId: body.userId,
reason: body.reason ?? null,
note: body.note ?? null,
createdById: session.user.id
},
update: {
reason: body.reason ?? null,
note: body.note ?? null
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: before ? 'users.blacklist.update' : 'users.blacklist.create',
targetType: 'GlobalUserBlacklist',
targetId: row.id,
before,
after: row
});
return NextResponse.json(row);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('SUPPORT');
const userId = new URL(request.url).searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.globalUserBlacklist.delete({ where: { userId } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'users.blacklist.delete',
targetType: 'GlobalUserBlacklist',
targetId: before.id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}