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:
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
17
apps/webui/src/app/api/owner/audit/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
74
apps/webui/src/app/api/owner/flags/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
38
apps/webui/src/app/api/owner/overview/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
36
apps/webui/src/app/api/owner/presence/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
77
apps/webui/src/app/api/owner/team/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
73
apps/webui/src/app/api/owner/users/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { getManageableGuilds } from '@/lib/guilds';
|
||||
import { isOwnerUser } from '@/lib/owner-auth';
|
||||
|
||||
interface GuildLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -20,9 +21,16 @@ export default async function GuildLayout({ children, params }: GuildLayoutProps
|
||||
}
|
||||
|
||||
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
|
||||
const showOwnerLink = await isOwnerUser(session.user.id);
|
||||
|
||||
return (
|
||||
<DashboardShell guildId={guildId} currentGuild={currentGuild} otherGuilds={otherGuilds} user={session.user}>
|
||||
<DashboardShell
|
||||
guildId={guildId}
|
||||
currentGuild={currentGuild}
|
||||
otherGuilds={otherGuilds}
|
||||
user={session.user}
|
||||
showOwnerLink={showOwnerLink}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
37
apps/webui/src/app/owner/audit/page.tsx
Normal file
37
apps/webui/src/app/owner/audit/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
|
||||
export default async function OwnerAuditPage() {
|
||||
const [locale, entries] = await Promise.all([getLocale(), listOwnerAudit(100)]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.audit.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.audit.subtitle')}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="divide-y divide-border p-0">
|
||||
{entries.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t(locale, 'owner.common.empty')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.id} className="flex flex-col gap-1 px-4 py-3 text-sm sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{entry.action}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{entry.actorUserId}
|
||||
{entry.targetType ? ` · ${entry.targetType}` : ''}
|
||||
{entry.targetId ? ` · ${entry.targetId}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/webui/src/app/owner/changelog/page.tsx
Normal file
28
apps/webui/src/app/owner/changelog/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ChangelogManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerChangelogPage() {
|
||||
const [locale, entries] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.changelog.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.changelog.subtitle')}</p>
|
||||
</div>
|
||||
<ChangelogManager
|
||||
initialEntries={entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
title: entry.title,
|
||||
body: entry.body,
|
||||
publishedAt: entry.publishedAt.toISOString()
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/webui/src/app/owner/flags/page.tsx
Normal file
20
apps/webui/src/app/owner/flags/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FlagsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerFlagsPage() {
|
||||
const [locale, flags] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.featureFlag.findMany({ orderBy: { key: 'asc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.flags.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.flags.subtitle')}</p>
|
||||
</div>
|
||||
<FlagsManager initialFlags={flags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/webui/src/app/owner/guilds/page.tsx
Normal file
33
apps/webui/src/app/owner/guilds/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { GuildsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerGuildsPage() {
|
||||
const locale = await getLocale();
|
||||
const [guilds, blacklist] = await Promise.all([
|
||||
prisma.guild.findMany({
|
||||
include: { settings: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200
|
||||
}),
|
||||
prisma.guildBlacklist.findMany()
|
||||
]);
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
||||
</div>
|
||||
<GuildsManager
|
||||
initialGuilds={guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/webui/src/app/owner/jobs/page.tsx
Normal file
30
apps/webui/src/app/owner/jobs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { JobsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getAllQueueStats } from '@/lib/owner-jobs';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerJobsPage() {
|
||||
const locale = await getLocale();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.jobs.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.jobs.subtitle')}</p>
|
||||
</div>
|
||||
<JobsManager
|
||||
initialQueues={queues}
|
||||
initialMigrations={migrations.map((row) => ({
|
||||
name: row.migration_name,
|
||||
finishedAt: row.finished_at?.toISOString() ?? null
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/webui/src/app/owner/layout.tsx
Normal file
7
apps/webui/src/app/owner/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { OwnerShell } from '@/components/owner/owner-shell';
|
||||
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||
|
||||
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireOwnerOrRedirect('VIEWER');
|
||||
return <OwnerShell user={session.user}>{children}</OwnerShell>;
|
||||
}
|
||||
97
apps/webui/src/app/owner/page.tsx
Normal file
97
apps/webui/src/app/owner/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerOverviewPage() {
|
||||
const locale = await getLocale();
|
||||
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 })
|
||||
]);
|
||||
|
||||
const cards = [
|
||||
{ label: t(locale, 'owner.overview.guilds'), value: String(guildCount) },
|
||||
{ label: t(locale, 'owner.overview.blacklistedUsers'), value: String(blacklistUsers) },
|
||||
{ label: t(locale, 'owner.overview.blacklistedGuilds'), value: String(blacklistGuilds) },
|
||||
{
|
||||
label: t(locale, 'owner.overview.maintenance'),
|
||||
value: presence.maintenanceMode ? t(locale, 'common.yes') : t(locale, 'common.no')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'owner.overview.uptime'),
|
||||
value: `${Math.floor(process.uptime() / 60)} min`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.overview.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.overview.subtitle')}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{card.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{card.value}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t(locale, 'owner.overview.recentAudit')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyAudit')}</p>
|
||||
) : (
|
||||
audit.map((entry) => (
|
||||
<div key={entry.id} className="flex justify-between gap-2 border-b border-border/60 py-2 last:border-0">
|
||||
<span>
|
||||
{entry.action}
|
||||
{entry.targetId ? ` · ${entry.targetId}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Link href="/owner/audit" className="text-primary hover:underline">
|
||||
{t(locale, 'owner.overview.viewAudit')}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t(locale, 'owner.overview.changelog')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{changelog.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyChangelog')}</p>
|
||||
) : (
|
||||
changelog.map((entry) => (
|
||||
<div key={entry.id} className="border-b border-border/60 py-2 last:border-0">
|
||||
<p className="font-medium">
|
||||
{entry.version} — {entry.title}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Link href="/owner/changelog" className="text-primary hover:underline">
|
||||
{t(locale, 'owner.overview.manageChangelog')}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/webui/src/app/owner/presence/page.tsx
Normal file
16
apps/webui/src/app/owner/presence/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PresenceForm } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
|
||||
export default async function OwnerPresencePage() {
|
||||
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
|
||||
</div>
|
||||
<PresenceForm initialValue={config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/app/owner/team/page.tsx
Normal file
21
apps/webui/src/app/owner/team/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TeamManager } from '@/components/owner/owner-forms';
|
||||
import { env } from '@/lib/env';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerTeamPage() {
|
||||
const [locale, members] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.team.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.team.subtitle')}</p>
|
||||
</div>
|
||||
<TeamManager bootstrapOwnerIds={env.OWNER_USER_IDS ?? []} initialMembers={members} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/webui/src/app/owner/users/page.tsx
Normal file
20
apps/webui/src/app/owner/users/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { UsersManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerUsersPage() {
|
||||
const [locale, users] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.users.subtitle')}</p>
|
||||
</div>
|
||||
<UsersManager initialUsers={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user