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

@@ -8,6 +8,7 @@ import { t } from '@nexumi/shared';
import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { isMaintenanceMode } from './presence.js';
import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/commands.js';
@@ -91,8 +92,18 @@ export async function registerCommands() {
}
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const command = map.get(interaction.commandName);
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true
});
return;
}
const command = map.get(interaction.commandName);
if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;

View File

@@ -22,7 +22,21 @@ const EnvSchema = z.object({
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
TWITCH_CLIENT_SECRET: z.preprocess(emptyToUndefined, z.string().min(1).optional())
TWITCH_CLIENT_SECRET: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
OWNER_USER_IDS: z.preprocess(
emptyToUndefined,
z
.string()
.optional()
.transform((value) =>
value
? value
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
: []
)
)
});
export const env = EnvSchema.parse(process.env);

View File

@@ -119,6 +119,12 @@ if (!isShard) {
} catch (error) {
logger.error({ error }, 'Failed to register application commands');
}
try {
const { applyBotPresence } = await import('./presence.js');
await applyBotPresence(context);
} catch (error) {
logger.warn({ error }, 'Failed to apply initial bot presence');
}
});
client.on(Events.MessageCreate, async (message) => {

View File

@@ -20,6 +20,7 @@ import { pollAllFeeds } from './modules/feeds/index.js';
import { sendScheduledMessage } from './modules/scheduler/index.js';
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
import { runPresenceRefreshJob } from './presence.js';
import {
automodQueue,
automodQueueName,
@@ -32,6 +33,8 @@ import {
giveawayQueueName,
guildBackupQueueName,
moderationQueueName,
presenceQueue,
presenceQueueName,
reminderQueueName,
scheduleQueueName,
suggestionsQueueName,
@@ -304,6 +307,20 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed');
});
const presenceWorker = new Worker(
presenceQueueName,
async (job) => {
if (job.name !== 'presenceRefresh') {
return;
}
await runPresenceRefreshJob(context);
},
{ connection: redis }
);
presenceWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Presence refresh job failed');
});
return [
moderationWorker,
backupWorker,
@@ -317,7 +334,8 @@ export function startWorkers(context: BotContext): Worker[] {
feedsWorker,
scheduleWorker,
guildBackupWorker,
suggestionsWorker
suggestionsWorker,
presenceWorker
];
}
@@ -375,6 +393,16 @@ export async function ensureRecurringJobs(): Promise<void> {
removeOnFail: 20
}
);
await presenceQueue.add(
'presenceRefresh',
{},
{
repeat: { every: 60_000 },
removeOnComplete: 5,
removeOnFail: 5
}
);
}
async function runPgDumpBackup(): Promise<void> {

78
apps/bot/src/presence.ts Normal file
View File

@@ -0,0 +1,78 @@
import { ActivityType } from 'discord.js';
import { PRESENCE_REDIS_KEY, BotPresenceConfigSchema, type BotPresenceConfig } from '@nexumi/shared';
import { redis } from './redis.js';
import { logger } from './logger.js';
import type { BotContext } from './types.js';
const ACTIVITY_TYPE_MAP: Record<BotPresenceConfig['activityType'], ActivityType> = {
Playing: ActivityType.Playing,
Listening: ActivityType.Listening,
Watching: ActivityType.Watching,
Competing: ActivityType.Competing
};
export async function readPresenceConfig(context: BotContext): Promise<BotPresenceConfig> {
const cached = await redis.get(PRESENCE_REDIS_KEY);
if (cached) {
const parsed = BotPresenceConfigSchema.safeParse(JSON.parse(cached));
if (parsed.success) {
return parsed.data;
}
}
const row = await context.prisma.botPresenceConfig.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {}
});
const rotating = Array.isArray(row.rotatingMessages)
? row.rotatingMessages.filter((item: unknown): item is string => typeof item === 'string')
: [];
const config = BotPresenceConfigSchema.parse({
status: row.status,
activityType: row.activityType,
activityText: row.activityText,
rotatingMessages: rotating,
maintenanceMode: row.maintenanceMode,
maintenanceMessage: row.maintenanceMessage
});
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(config));
return config;
}
export async function applyBotPresence(context: BotContext): Promise<void> {
const config = await readPresenceConfig(context);
const messages =
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
const index = Math.floor(Date.now() / 60_000) % messages.length;
const text = messages[index] ?? config.activityText;
await context.client.user?.setPresence({
status: config.status,
activities: [
{
name: text,
type: ACTIVITY_TYPE_MAP[config.activityType]
}
]
});
}
export async function isMaintenanceMode(context: BotContext): Promise<{
enabled: boolean;
message: string | null;
}> {
const config = await readPresenceConfig(context);
return {
enabled: config.maintenanceMode,
message: config.maintenanceMessage
};
}
export async function runPresenceRefreshJob(context: BotContext): Promise<void> {
try {
await applyBotPresence(context);
} catch (error) {
logger.warn({ error }, 'Failed to apply bot presence');
}
}

View File

@@ -27,3 +27,5 @@ export const guildBackupQueueName = 'guild-backups';
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
export const suggestionsQueueName = 'suggestions';
export const suggestionsQueue = new Queue(suggestionsQueueName, { connection: redis });
export const presenceQueueName = 'presence';
export const presenceQueue = new Queue(presenceQueueName, { connection: redis });

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);
}
}

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -9,17 +9,25 @@ interface DashboardShellProps {
currentGuild: DashboardGuild;
otherGuilds: DashboardGuild[];
user: SessionUser;
showOwnerLink?: boolean;
children: ReactNode;
}
export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) {
export function DashboardShell({
guildId,
currentGuild,
otherGuilds,
user,
showOwnerLink = false,
children
}: DashboardShellProps) {
return (
<div className="flex min-h-screen">
<Sidebar guildId={guildId} />
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b border-border px-6">
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
<UserMenu user={user} />
<UserMenu user={user} showOwnerLink={showOwnerLink} />
</header>
<main className="flex-1 px-6 py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>

View File

@@ -1,7 +1,7 @@
'use client';
import type { SessionUser } from '@nexumi/shared';
import { LogOut, Moon, Sun } from 'lucide-react';
import { LogOut, Moon, Shield, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -26,7 +26,7 @@ function initials(user: SessionUser): string {
return name.slice(0, 2).toUpperCase();
}
export function UserMenu({ user }: { user: SessionUser }) {
export function UserMenu({ user, showOwnerLink = false }: { user: SessionUser; showOwnerLink?: boolean }) {
const t = useTranslations();
const { theme, setTheme } = useTheme();
const router = useRouter();
@@ -58,6 +58,19 @@ export function UserMenu({ user }: { user: SessionUser }) {
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
<DropdownMenuSeparator />
{showOwnerLink ? (
<>
<DropdownMenuItem
onClick={() => {
router.push('/owner');
}}
>
<Shield className="size-4" />
{t('userMenu.ownerPanel')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}

View File

@@ -0,0 +1,665 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react';
import { toast } from 'sonner';
import type { BotPresenceConfig, OwnerRole } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { SettingsForm } from '@/components/settings/settings-form';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
async function readError(response: Response): Promise<string> {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
return data?.error ?? 'Request failed';
}
export function PresenceForm({ initialValue }: { initialValue: BotPresenceConfig }) {
const t = useTranslations();
return (
<SettingsForm
initialValue={initialValue}
onSave={async (value) => {
const response = await fetch('/api/owner/presence', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
return { ok: false, error: await readError(response) };
}
return { ok: true };
}}
>
{({ value, setValue, error }) => (
<Card>
<CardContent className="space-y-4 pt-6">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('owner.presence.status')}</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={value.status}
onChange={(e) => setValue({ ...value, status: e.target.value as BotPresenceConfig['status'] })}
>
{['online', 'idle', 'dnd', 'invisible'].map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityType')}</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={value.activityType}
onChange={(e) =>
setValue({ ...value, activityType: e.target.value as BotPresenceConfig['activityType'] })
}
>
{['Playing', 'Listening', 'Watching', 'Competing'].map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityText')}</Label>
<Input value={value.activityText} onChange={(e) => setValue({ ...value, activityText: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('owner.presence.rotating')}</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={value.rotatingMessages.join('\n')}
onChange={(e) =>
setValue({
...value,
rotatingMessages: e.target.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
})
}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value.maintenanceMode}
onChange={(e) => setValue({ ...value, maintenanceMode: e.target.checked })}
/>
{t('owner.presence.maintenanceMode')}
</label>
<div className="space-y-2">
<Label>{t('owner.presence.maintenanceMessage')}</Label>
<Input
value={value.maintenanceMessage ?? ''}
onChange={(e) => setValue({ ...value, maintenanceMessage: e.target.value || null })}
/>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}
export function UsersManager({
initialUsers
}: {
initialUsers: Array<{ id: string; userId: string; reason: string | null; note: string | null }>;
}) {
const t = useTranslations();
const router = useRouter();
const [userId, setUserId] = useState('');
const [reason, setReason] = useState('');
const [pending, startTransition] = useTransition();
return (
<div className="space-y-4">
<Card>
<CardContent className="space-y-3 pt-6">
<Input placeholder={t('owner.users.userIdPlaceholder')} value={userId} onChange={(e) => setUserId(e.target.value)} />
<Input placeholder={t('owner.common.noteOptional')} value={reason} onChange={(e) => setReason(e.target.value)} />
<Button
disabled={pending || !userId.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: userId.trim(), reason: reason.trim() || null })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setUserId('');
setReason('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="divide-y divide-border p-0">
{initialUsers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
initialUsers.map((user) => (
<div key={user.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">{user.userId}</p>
{(user.reason || user.note) && (
<p className="text-muted-foreground">{user.reason ?? user.note}</p>
)}
</div>
<Button
variant="destructive"
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/users?userId=${user.userId}`, { method: 'DELETE' });
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}
export function GuildsManager({
initialGuilds
}: {
initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>;
}) {
const t = useTranslations();
const router = useRouter();
const [query, setQuery] = useState('');
const [pending, startTransition] = useTransition();
const filtered = useMemo(
() => initialGuilds.filter((guild) => guild.id.includes(query.trim())),
[initialGuilds, query]
);
async function postAction(action: string, guildId: string, reason?: string) {
const response = await fetch('/api/owner/guilds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, guildId, reason })
});
if (!response.ok) {
throw new Error(await readError(response));
}
}
return (
<div className="space-y-4">
<Input placeholder={t('owner.guilds.search')} value={query} onChange={(e) => setQuery(e.target.value)} />
<Card>
<CardContent className="divide-y divide-border p-0">
{filtered.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
filtered.map((guild) => (
<div key={guild.id} className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">{guild.id}</p>
<p className="text-muted-foreground">
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
disabled={pending}
onClick={() =>
startTransition(async () => {
try {
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
toast.success(t('common.saveSuccess'));
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
</Button>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
return;
}
try {
await postAction('leave', guild.id);
toast.success(t('common.saveSuccess'));
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{t('owner.guilds.leave')}
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}
export function FlagsManager({
initialFlags
}: {
initialFlags: Array<{
id: string;
key: string;
enabled: boolean;
rolloutPercent: number;
whitelistGuildIds: string[];
}>;
}) {
const t = useTranslations();
const router = useRouter();
const [key, setKey] = useState('');
const [rollout, setRollout] = useState('0');
const [pending, startTransition] = useTransition();
return (
<div className="space-y-4">
<Card>
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
<Input placeholder={t('owner.flags.keyPlaceholder')} value={key} onChange={(e) => setKey(e.target.value)} />
<Input
type="number"
min={0}
max={100}
value={rollout}
onChange={(e) => setRollout(e.target.value)}
placeholder={t('owner.flags.rollout')}
/>
<Button
disabled={pending || !key.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/flags', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: key.trim(),
enabled: true,
rolloutPercent: Number(rollout) || 0,
whitelistGuildIds: []
})
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setKey('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="divide-y divide-border p-0">
{initialFlags.map((flag) => (
<div key={flag.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">{flag.key}</p>
<p className="text-muted-foreground">
{flag.enabled ? t('common.enabled') : t('common.disabled')} · {flag.rolloutPercent}%
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/flags', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: flag.key,
enabled: !flag.enabled,
rolloutPercent: flag.rolloutPercent,
whitelistGuildIds: flag.whitelistGuildIds
})
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
router.refresh();
})
}
>
{flag.enabled ? t('common.disabled') : t('common.enabled')}
</Button>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/flags?key=${encodeURIComponent(flag.key)}`, {
method: 'DELETE'
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
export function TeamManager({
bootstrapOwnerIds,
initialMembers
}: {
bootstrapOwnerIds: string[];
initialMembers: Array<{ id: string; userId: string; role: string; note: string | null }>;
}) {
const t = useTranslations();
const router = useRouter();
const [userId, setUserId] = useState('');
const [role, setRole] = useState<OwnerRole>('SUPPORT');
const [pending, startTransition] = useTransition();
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('owner.team.bootstrap')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{bootstrapOwnerIds.length === 0
? t('owner.team.noBootstrap')
: bootstrapOwnerIds.join(', ')}
</CardContent>
</Card>
<Card>
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
<Input placeholder={t('owner.users.userIdPlaceholder')} value={userId} onChange={(e) => setUserId(e.target.value)} />
<select
className="flex h-10 rounded-md border border-input bg-background px-3 text-sm"
value={role}
onChange={(e) => setRole(e.target.value as OwnerRole)}
>
{(['VIEWER', 'SUPPORT', 'ADMIN', 'OWNER'] as OwnerRole[]).map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<Button
disabled={pending || !userId.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/team', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: userId.trim(), role, note: null })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setUserId('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="divide-y divide-border p-0">
{initialMembers.map((member) => (
<div key={member.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">{member.userId}</p>
<p className="text-muted-foreground">{member.role}</p>
</div>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/team?userId=${member.userId}`, { method: 'DELETE' });
if (!response.ok) {
toast.error(await readError(response));
return;
}
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))}
</CardContent>
</Card>
</div>
);
}
export function JobsManager({
initialQueues,
initialMigrations
}: {
initialQueues: Array<{
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}>;
initialMigrations: Array<{ name: string; finishedAt: string | null }>;
}) {
const t = useTranslations();
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('owner.jobs.queues')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{initialQueues.map((queue) => (
<div key={queue.name} className="flex flex-col gap-2 border-b border-border/60 py-3 text-sm last:border-0 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">{queue.name}</p>
<p className="text-muted-foreground">
wait {queue.waiting} · active {queue.active} · failed {queue.failed} · delayed {queue.delayed}
</p>
</div>
<Button
size="sm"
variant="secondary"
disabled={pending || queue.failed === 0}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queueName: queue.name })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('owner.jobs.retryFailed')}
</Button>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('owner.jobs.migrations')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{initialMigrations.map((migration) => (
<div key={migration.name} className="flex justify-between gap-2">
<span>{migration.name}</span>
<span className="text-muted-foreground">
{migration.finishedAt ? new Date(migration.finishedAt).toLocaleString() : '—'}
</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}
export function ChangelogManager({
initialEntries
}: {
initialEntries: Array<{ id: string; version: string; title: string; body: string; publishedAt: string }>;
}) {
const t = useTranslations();
const router = useRouter();
const [version, setVersion] = useState('');
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [pending, startTransition] = useTransition();
return (
<div className="space-y-4">
<Card>
<CardContent className="space-y-3 pt-6">
<Input placeholder={t('owner.changelog.version')} value={version} onChange={(e) => setVersion(e.target.value)} />
<Input placeholder={t('owner.changelog.titleField')} value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea
className="min-h-28 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t('owner.changelog.body')}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
disabled={pending || !version.trim() || !title.trim() || !body.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/changelog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: version.trim(), title: title.trim(), body: body.trim() })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setVersion('');
setTitle('');
setBody('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="divide-y divide-border p-0">
{initialEntries.map((entry) => (
<div key={entry.id} className="flex items-start justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">
{entry.version} {entry.title}
</p>
<p className="text-muted-foreground whitespace-pre-wrap">{entry.body}</p>
</div>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/changelog?id=${entry.id}`, { method: 'DELETE' });
if (!response.ok) {
toast.error(await readError(response));
return;
}
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type { SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react';
import { OwnerSidebar } from '@/components/owner/owner-sidebar';
import { UserMenu } from '@/components/layout/user-menu';
export function OwnerShell({
user,
children
}: {
user: SessionUser;
children: ReactNode;
}) {
return (
<div className="flex min-h-screen">
<OwnerSidebar />
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-end border-b border-border px-6">
<UserMenu user={user} showOwnerLink />
</header>
<main className="flex-1 px-6 py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import {
Activity,
BookOpen,
Flag,
LayoutDashboard,
ListTodo,
Radio,
ScrollText,
Server,
Users,
ArrowLeft
} from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider';
import { cn } from '@/lib/utils';
const LINKS = [
{ href: '/owner', icon: LayoutDashboard, key: 'overview' },
{ href: '/owner/guilds', icon: Server, key: 'guilds' },
{ href: '/owner/users', icon: Users, key: 'users' },
{ href: '/owner/flags', icon: Flag, key: 'flags' },
{ href: '/owner/presence', icon: Radio, key: 'presence' },
{ href: '/owner/team', icon: Users, key: 'team' },
{ href: '/owner/jobs', icon: ListTodo, key: 'jobs' },
{ href: '/owner/changelog', icon: BookOpen, key: 'changelog' },
{ href: '/owner/audit', icon: ScrollText, key: 'audit' }
] as const;
export function OwnerSidebar() {
const pathname = usePathname();
const t = useTranslations();
return (
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
<Link
href="/dashboard"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent/60 hover:text-foreground"
>
<ArrowLeft className="size-4" />
{t('owner.nav.backToDashboard')}
</Link>
<div className="flex flex-col gap-1">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('owner.nav.title')}
</p>
{LINKS.map((link) => {
const active = link.href === '/owner' ? pathname === '/owner' : pathname.startsWith(link.href);
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
<Icon className="size-4" />
{t(`owner.nav.${link.key}`)}
</Link>
);
})}
</div>
<div className="mt-auto px-3 text-xs text-muted-foreground">
<Activity className="mb-1 size-3.5" />
Nexumi Owner
</div>
</nav>
);
}

View File

@@ -0,0 +1,51 @@
import { Prisma, type PrismaClient } from '@prisma/client';
import type { OwnerAuditEntry } from '@nexumi/shared';
import { prisma } from './prisma';
export interface WriteOwnerAuditInput {
actorUserId: string;
action: string;
targetType?: string | null;
targetId?: string | null;
before?: unknown;
after?: unknown;
}
function toJson(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
if (value === undefined || value === null) {
return Prisma.JsonNull;
}
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
export async function writeOwnerAudit(
client: PrismaClient,
input: WriteOwnerAuditInput
): Promise<void> {
await client.ownerAuditLog.create({
data: {
actorUserId: input.actorUserId,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
before: toJson(input.before),
after: toJson(input.after)
}
});
}
export async function listOwnerAudit(limit = 50, offset = 0): Promise<OwnerAuditEntry[]> {
const rows = await prisma.ownerAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset
});
return rows.map((row) => ({
id: row.id,
actorUserId: row.actorUserId,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
createdAt: row.createdAt.toISOString()
}));
}

View File

@@ -0,0 +1,55 @@
import { redirect } from 'next/navigation';
import {
ownerRoleAtLeast,
OwnerRoleSchema,
type OwnerRole
} from '@nexumi/shared';
import { ForbiddenError, UnauthorizedError, requireAuth, requireAuthOrRedirect } from './auth';
import { env } from './env';
import { prisma } from './prisma';
import type { SessionPayload } from './session';
export type OwnerSession = SessionPayload & { ownerRole: OwnerRole };
function bootstrapOwnerIds(): string[] {
return env.OWNER_USER_IDS ?? [];
}
export async function resolveOwnerRole(userId: string): Promise<OwnerRole | null> {
if (bootstrapOwnerIds().includes(userId)) {
return 'OWNER';
}
const member = await prisma.ownerTeamMember.findUnique({ where: { userId } });
if (!member) {
return null;
}
const parsed = OwnerRoleSchema.safeParse(member.role);
return parsed.success ? parsed.data : null;
}
export async function isOwnerUser(userId: string): Promise<boolean> {
return (await resolveOwnerRole(userId)) !== null;
}
export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
const session = await requireAuth();
const role = await resolveOwnerRole(session.user.id);
if (!role) {
throw new ForbiddenError('Owner panel access required');
}
if (!ownerRoleAtLeast(role, minimum)) {
throw new ForbiddenError('Insufficient owner role');
}
return { ...session, ownerRole: role };
}
export async function requireOwnerOrRedirect(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
const session = await requireAuthOrRedirect();
const role = await resolveOwnerRole(session.user.id);
if (!role || !ownerRoleAtLeast(role, minimum)) {
redirect('/dashboard');
}
return { ...session, ownerRole: role };
}
export { UnauthorizedError, ForbiddenError };

View File

@@ -0,0 +1,76 @@
import { Queue } from 'bullmq';
import { redis } from './redis';
export const OWNER_QUEUE_NAMES = [
'moderation',
'backups',
'automod',
'verification',
'reminders',
'giveaways',
'tickets',
'birthdays',
'stats',
'feeds',
'schedules',
'guild-backups',
'suggestions',
'presence'
] as const;
export type OwnerQueueName = (typeof OWNER_QUEUE_NAMES)[number];
const globalForOwnerQueues = globalThis as unknown as {
__nexumiOwnerQueues?: Map<string, Queue>;
};
function getQueue(name: string): Queue {
const cache = globalForOwnerQueues.__nexumiOwnerQueues ?? new Map<string, Queue>();
globalForOwnerQueues.__nexumiOwnerQueues = cache;
let queue = cache.get(name);
if (!queue) {
queue = new Queue(name, { connection: redis });
cache.set(name, queue);
}
return queue;
}
export interface QueueStats {
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}
export async function getAllQueueStats(): Promise<QueueStats[]> {
return Promise.all(
OWNER_QUEUE_NAMES.map(async (name) => {
const queue = getQueue(name);
const counts = await queue.getJobCounts('wait', 'active', 'completed', 'failed', 'delayed');
return {
name,
waiting: counts.wait,
active: counts.active,
completed: counts.completed,
failed: counts.failed,
delayed: counts.delayed
};
})
);
}
export async function retryFailedJobs(queueName: string, limit = 25): Promise<number> {
if (!(OWNER_QUEUE_NAMES as readonly string[]).includes(queueName)) {
throw new Error('Unknown queue');
}
const queue = getQueue(queueName);
const failed = await queue.getFailed(0, limit - 1);
let retried = 0;
for (const job of failed) {
await job.retry();
retried += 1;
}
return retried;
}

View File

@@ -0,0 +1,67 @@
import {
BotPresenceConfigPatchSchema,
BotPresenceConfigSchema,
PRESENCE_REDIS_KEY,
type BotPresenceConfig
} from '@nexumi/shared';
import { prisma } from './prisma';
import { redis } from './redis';
function mapRow(row: {
status: string;
activityType: string;
activityText: string;
rotatingMessages: unknown;
maintenanceMode: boolean;
maintenanceMessage: string | null;
}): BotPresenceConfig {
const rotating = Array.isArray(row.rotatingMessages)
? row.rotatingMessages.filter((item): item is string => typeof item === 'string')
: [];
return BotPresenceConfigSchema.parse({
status: row.status,
activityType: row.activityType,
activityText: row.activityText,
rotatingMessages: rotating,
maintenanceMode: row.maintenanceMode,
maintenanceMessage: row.maintenanceMessage
});
}
export async function getPresenceConfig(): Promise<BotPresenceConfig> {
const row = await prisma.botPresenceConfig.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {}
});
return mapRow(row);
}
export async function updatePresenceConfig(patch: unknown): Promise<BotPresenceConfig> {
const data = BotPresenceConfigPatchSchema.parse(patch);
const current = await getPresenceConfig();
const next = { ...current, ...data };
const row = await prisma.botPresenceConfig.upsert({
where: { id: 'singleton' },
create: {
id: 'singleton',
status: next.status,
activityType: next.activityType,
activityText: next.activityText,
rotatingMessages: next.rotatingMessages,
maintenanceMode: next.maintenanceMode,
maintenanceMessage: next.maintenanceMessage
},
update: {
status: next.status,
activityType: next.activityType,
activityText: next.activityText,
rotatingMessages: next.rotatingMessages,
maintenanceMode: next.maintenanceMode,
maintenanceMessage: next.maintenanceMessage
}
});
const mapped = mapRow(row);
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
return mapped;
}

View File

@@ -518,7 +518,97 @@
"themeDark": "Dunkel",
"themeLight": "Hell",
"logout": "Abmelden",
"loggingOut": "Meldet ab…"
"loggingOut": "Meldet ab…",
"ownerPanel": "Owner-Panel"
},
"owner": {
"nav": {
"title": "Owner",
"backToDashboard": "Zum Dashboard",
"overview": "Übersicht",
"guilds": "Server",
"users": "User-Blacklist",
"flags": "Feature-Flags",
"presence": "Präsenz",
"team": "Team",
"jobs": "Jobs",
"changelog": "Changelog",
"audit": "Audit"
},
"common": {
"empty": "Keine Einträge.",
"noteOptional": "Notiz (optional)"
},
"overview": {
"title": "Owner-Übersicht",
"subtitle": "Globale Bot-Kennzahlen und letzte Owner-Aktionen.",
"guilds": "Server",
"blacklistedUsers": "Gesperrte User",
"blacklistedGuilds": "Gesperrte Server",
"maintenance": "Wartungsmodus",
"uptime": "WebUI-Uptime",
"recentAudit": "Letzte Owner-Aktivität",
"emptyAudit": "Noch keine Owner-Aktionen.",
"viewAudit": "Audit öffnen",
"changelog": "Changelog",
"emptyChangelog": "Noch keine Changelog-Einträge.",
"manageChangelog": "Changelog verwalten"
},
"guilds": {
"title": "Server-Verwaltung",
"subtitle": "Server suchen, blacklisten oder den Bot entfernen.",
"search": "Server-ID suchen…",
"blacklisted": "Blacklist",
"blacklist": "Blacklisten",
"unblacklist": "Blacklist entfernen",
"leave": "Server verlassen",
"leaveConfirm": "Bot wirklich von diesem Server entfernen?"
},
"users": {
"title": "User-Blacklist",
"subtitle": "Global gesperrte Discord-User (Bot ignoriert sie überall).",
"userIdPlaceholder": "Discord User-ID"
},
"flags": {
"title": "Feature-Flags",
"subtitle": "Module global steuern und Rollouts konfigurieren.",
"keyPlaceholder": "Flag-Key (z. B. new.module)",
"rollout": "Rollout %"
},
"presence": {
"title": "Bot-Präsenz",
"subtitle": "Status, Aktivität und Wartungsmodus.",
"status": "Status",
"activityType": "Aktivitätstyp",
"activityText": "Aktivitätstext",
"rotating": "Rotierende Nachrichten (eine pro Zeile)",
"maintenanceMode": "Wartungsmodus aktiv",
"maintenanceMessage": "Wartungsnachricht"
},
"team": {
"title": "Team",
"subtitle": "Weitere Owner/Admins mit abgestuften Rechten.",
"bootstrap": "Bootstrap-Owner (OWNER_USER_IDS)",
"noBootstrap": "Keine Bootstrap-Owner in OWNER_USER_IDS gesetzt."
},
"jobs": {
"title": "Jobs & Migrationen",
"subtitle": "BullMQ-Queues und Prisma-Migrationsstatus.",
"queues": "Queues",
"migrations": "Migrationen",
"retryFailed": "Fehlgeschlagene neu starten"
},
"changelog": {
"title": "Changelog",
"subtitle": "Versionshinweise für das Dashboard.",
"version": "Version",
"titleField": "Titel",
"body": "Inhalt"
},
"audit": {
"title": "Owner-Audit",
"subtitle": "Alle Owner-Aktionen im globalen Audit-Log."
}
},
"serverSwitcher": {
"switchServer": "Server wechseln",

View File

@@ -518,7 +518,97 @@
"themeDark": "Dark",
"themeLight": "Light",
"logout": "Log out",
"loggingOut": "Logging out…"
"loggingOut": "Logging out…",
"ownerPanel": "Owner panel"
},
"owner": {
"nav": {
"title": "Owner",
"backToDashboard": "Back to dashboard",
"overview": "Overview",
"guilds": "Guilds",
"users": "User blacklist",
"flags": "Feature flags",
"presence": "Presence",
"team": "Team",
"jobs": "Jobs",
"changelog": "Changelog",
"audit": "Audit"
},
"common": {
"empty": "No entries.",
"noteOptional": "Note (optional)"
},
"overview": {
"title": "Owner overview",
"subtitle": "Global bot metrics and recent owner actions.",
"guilds": "Guilds",
"blacklistedUsers": "Blacklisted users",
"blacklistedGuilds": "Blacklisted guilds",
"maintenance": "Maintenance mode",
"uptime": "WebUI uptime",
"recentAudit": "Recent owner activity",
"emptyAudit": "No owner actions yet.",
"viewAudit": "Open audit",
"changelog": "Changelog",
"emptyChangelog": "No changelog entries yet.",
"manageChangelog": "Manage changelog"
},
"guilds": {
"title": "Guild management",
"subtitle": "Search guilds, blacklist them, or leave with the bot.",
"search": "Search guild ID…",
"blacklisted": "Blacklisted",
"blacklist": "Blacklist",
"unblacklist": "Remove blacklist",
"leave": "Leave guild",
"leaveConfirm": "Really remove the bot from this guild?"
},
"users": {
"title": "User blacklist",
"subtitle": "Globally blocked Discord users (bot ignores them everywhere).",
"userIdPlaceholder": "Discord user ID"
},
"flags": {
"title": "Feature flags",
"subtitle": "Control modules globally and configure rollouts.",
"keyPlaceholder": "Flag key (e.g. new.module)",
"rollout": "Rollout %"
},
"presence": {
"title": "Bot presence",
"subtitle": "Status, activity, and maintenance mode.",
"status": "Status",
"activityType": "Activity type",
"activityText": "Activity text",
"rotating": "Rotating messages (one per line)",
"maintenanceMode": "Maintenance mode enabled",
"maintenanceMessage": "Maintenance message"
},
"team": {
"title": "Team",
"subtitle": "Additional owners/admins with graded permissions.",
"bootstrap": "Bootstrap owners (OWNER_USER_IDS)",
"noBootstrap": "No bootstrap owners set in OWNER_USER_IDS."
},
"jobs": {
"title": "Jobs & migrations",
"subtitle": "BullMQ queues and Prisma migration status.",
"queues": "Queues",
"migrations": "Migrations",
"retryFailed": "Retry failed"
},
"changelog": {
"title": "Changelog",
"subtitle": "Release notes for the dashboard.",
"version": "Version",
"titleField": "Title",
"body": "Body"
},
"audit": {
"title": "Owner audit",
"subtitle": "All owner actions in the global audit log."
}
},
"serverSwitcher": {
"switchServer": "Switch server",

View File

@@ -24,5 +24,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/dashboard/:path*']
matcher: ['/dashboard/:path*', '/owner/:path*']
};

View File

@@ -1,48 +1,25 @@
## Phase 6 WebUI-Fundament (Status: abgeschlossen, manuell bestätigt)
- OAuth/Sessions, Settings-Framework, Layout, Modul-Toggles, Access-Rules
- Login manuell bestätigt (`WEBUI_URL=http://10.111.0.65:3000`)
## Phase 7 WebUI Modul-Seiten + Owner-Panel (Status: implementiert, manuelle Tests ausstehend)
### Abgeschlossen
- `apps/webui` als `@nexumi/webui` (Next.js 15 App Router, Tailwind 4, React 19)
- Discord-OAuth2 + Redis-Sessions; Cookie-Fix für HTTP (`Secure` nur bei HTTPS; Set-Cookie am Redirect-Response)
- Settings-Framework, Layout, Shared Zod-API, Modul-Toggles, Access-Rules, Dashboard-Audit
- Deploy: bot+webui healthy; Login manuell bestätigt (`WEBUI_URL=http://10.111.0.65:3000`)
- **Batch A:** Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun + Prisma Owner-Modelle (`20260722190000_phase7_owner`)
- **Batch B:** Tickets, Giveaways, SelfRoles, Tags, Starboard, Suggestions, Birthdays, TempVoice, Stats, Feeds, Scheduler, Backup, Commands-Seite, Overview-Aktivität; BullMQ-Anbindung WebUI↔Bot
- **Batch C (Owner-Panel):** `/owner` Übersicht, Guilds (Leave/Blacklist), User-Blacklist, Feature-Flags, Presence (+ Bot-Job `presence` + Wartungsmodus in `routeCommand`), Team, Jobs/Migrationen, Changelog, Owner-Audit; Auth über `OWNER_USER_IDS` + `OwnerTeamMember`
- Shared: `dashboard.ts`, `owner.ts` + Tests
- Checks: shared 46 Tests, webui typecheck/lint, bot typecheck grün
## Phase 7 WebUI Modul-Seiten + Owner-Panel (Status: in Arbeit)
### Manuell testen
### Abgeschlossen (Batch A)
- Prisma-Grundlage für Owner-Features: `CommandOverride`, `OwnerTeamMember`, `GlobalUserBlacklist`, `GuildBlacklist`, `FeatureFlag`, `BotPresenceConfig`, `ChangelogEntry`, `OwnerAuditLog` inkl. Migration `20260722190000_phase7_owner`
- Shared Zod-Schemas für Dashboard-Module in `packages/shared/src/dashboard.ts` (Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun, Cases, Warnings) + Tests
- `OWNER_USER_IDS`-Env-Variable (WebUI-Env-Schema + `.env.example`), noch ohne Owner-Panel-Nutzung
- API-Routen (GET+PATCH) für: `moderation`, `automod`, `logging`, `welcome`, `verification`, `leveling`, `economy`, `fun`; zusätzlich GET `cases` und `warnings`
- Funktionierende Dashboard-Seiten (Formular + Speichern über SettingsForm/SaveBar, echte Daten, keine Platzhalter) für alle acht Batch-A-Module, inkl. Cases-Tabelle auf der Moderation-Seite
- i18n-Keys `modulePages.*` in `de.json` und `en.json` ergänzt
- Platzhalter-Route `dashboard/[guildId]/[module]/page.tsx` greift nur noch für die verbleibenden Batch-B/C-Module (Batch-A-Routen haben eigene Ordner mit Vorrang)
- Verifiziert: `pnpm --filter @nexumi/shared build+test`, `pnpm --filter @nexumi/webui typecheck+lint`, `pnpm --filter @nexumi/bot typecheck+lint+test` grün; `next build` kompiliert und rendert alle Seiten (inkl. neue Batch-A-Routen) erfolgreich einzige Auffälligkeit ist ein bekanntes Windows-only `EPERM`-Symlink-Problem bei `output: standalone` außerhalb von Docker, ohne Auswirkung auf den Linux-Container-Build
### Abgeschlossen (Batch B)
- Shared Zod-Schemas in `packages/shared/src/dashboard.ts` für alle restlichen Module ergänzt: Tickets (Config + Kategorien), Giveaways (+ Create/Action), SelfRoles, Tags, Starboard, Suggestions (Config + Liste + Action), Birthdays, TempVoice, Stats, SocialFeeds, Scheduler, GuildBackups, CommandOverrides, `ActivityStatSummary`, `KNOWN_COMMAND_NAMES` inkl. neuer Tests in `dashboard.test.ts`
- WebUI-BullMQ-Infrastruktur (`apps/webui/src/lib/queues.ts`): eigene `Queue`/`QueueEvents`-Instanzen für `giveaways`, `schedules`, `guild-backups`, `suggestions` (dieselben Queue-Namen wie im Bot), plus `addJobAndAwait`-Helper für bounded-wait auf Bot-Jobs
- `BOT_TOKEN` zusätzlich im WebUI-Env-Schema (`apps/webui/src/lib/env.ts`) für schmale Discord-REST-Aufrufe (Guild-Backup-Snapshot) und BullMQ-Jobs
- Bot-seitige Erweiterungen für Dashboard-Aktionen, die einen laufenden discord.js-Client brauchen:
- `giveaways`: neuer `giveawayCreate`-Job (`runGiveawayCreateJob`) für Dashboard-Erstellung, Worker in `apps/bot/src/jobs.ts` erweitert
- `suggestions`: neue Queue `suggestions` + Job `suggestionStatusUpdate` (`runSuggestionStatusUpdateJob`) für Dashboard-Staff-Actions (Approve/Deny/Consider/Implement inkl. Embed-Move + Autor-DM)
- `guildbackup`: Restore weiterhin über bestehende `guild-backups`-Queue (`restoreGuildBackup`), jetzt auch von der WebUI aus angestoßen
- Modul-Configs (`apps/webui/src/lib/module-configs/*`) + API-Routen (`apps/webui/src/app/api/guilds/[guildId]/...`) + Formulare/Manager (`apps/webui/src/components/modules/*`) + Seiten/Loading (`apps/webui/src/app/dashboard/[guildId]/<href>/`) für: `tickets`, `giveaways`, `selfroles`, `tags`, `starboard`, `suggestions`, `birthdays`, `tempvoice`, `stats`, `feeds`, `scheduler`, `backup` (Href für `guildbackup`)
- Guild-Backup: Liste/Info/Download/Delete direkt über Prisma; Create via schmalem Discord-REST-Snapshot (`apps/webui/src/lib/guild-backup.ts`); Restore nur für den Discord-Server-Owner (Check über OAuth-Guild-Liste `guild.owner`), mit doppelter Bestätigung im UI
- Scheduler: Dashboard-Erstellung spiegelt exakt die Job-Optionen von `apps/bot/src/modules/scheduler/service.ts` (`schedule-{id}`-JobId, `scheduleSend`-Jobname, gleiche Queue) kein separater Bot-seitiger Reconciler nötig
- `/dashboard/[guildId]/commands` (CommandOverride-CRUD auf Basis von `KNOWN_COMMAND_NAMES`, Toggle + Cooldown + Rollen-/Kanal-Listen), Sidebar-Eintrag im Settings-Bereich ergänzt
- Overview-Seite um 7-Tage-`ActivityStat`-Aggregate erweitert (Nachrichten, Voice-Minuten, aktive Mitglieder) via `apps/webui/src/lib/activity.ts`
- Platzhalter-Route `dashboard/[guildId]/[module]/page.tsx` entfernt (alle Module haben jetzt eigene Ordner)
- i18n-Keys `modulePages.*` für alle Batch-B-Module + `nav.commands` + `common.optional` + `dashboard.overview.activity*` in `de.json` und `en.json` ergänzt
- Verifiziert: `pnpm --filter @nexumi/shared build+test`, `pnpm --filter @nexumi/webui typecheck+lint`, `pnpm --filter @nexumi/bot typecheck+lint+test` grün
### Offen (Batch C)
- Owner-Panel-UI (Übersicht, Guilds, User-Blacklist, Feature-Flags, Präsenz, Team, Jobs, Changelog, Audit) auf Basis der in Batch A angelegten Prisma-Modelle
- Manuelle Tests der Batch-B-Seiten im laufenden Stack (Docker: bot + webui + Redis + Postgres), insbesondere: Giveaway-Create/End über BullMQ, Suggestion-Staff-Actions, Guild-Backup-Restore (Owner-Check), Scheduler-Job-Aufnahme durch den Bot-Worker
1. `OWNER_USER_IDS=<deine Discord-User-ID>` in `.env` setzen, Stack neu starten
2. Dashboard-Modul-Seiten speichern (Settings/Module/Cases etc.)
3. Owner-Panel: `/owner` → Presence, Flags, Team, Jobs
4. Giveaway/Suggestion/Backup-Aktionen aus dem Dashboard (BullMQ)
## Nächster geplanter Schritt
- Batch C (Owner-Panel-UI) umsetzen, danach Phase 7 komplett manuell freigeben, anschließend Phase 8 (Landing, Status, Rechtsseiten).
- Nach Freigabe Phase 8 (Landing Page, Status-Seite, Rechtsseiten-Gerüst).

View File

@@ -9,6 +9,7 @@ export * from './phase4.js';
export * from './phase5.js';
export * from './phase6.js';
export * from './dashboard.js';
export * from './owner.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -49,6 +50,7 @@ const de: Dictionary = {
'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).',
'generic.invalidRegex': 'Ungültiges Regex-Muster.',
'generic.error': 'Es ist ein Fehler aufgetreten.',
'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.',
'generic.unknownCommand': 'Unbekannter Befehl.',
'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.reason.none': 'Kein Grund angegeben',
@@ -523,6 +525,7 @@ const en: Dictionary = {
'generic.botMissingPermission': 'Bot lacks required permission ({permission}).',
'generic.invalidRegex': 'Invalid regex pattern.',
'generic.error': 'An error occurred.',
'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.',
'generic.unknownCommand': 'Unknown command.',
'moderation.success': 'Action executed successfully.',
'moderation.reason.none': 'No reason provided',

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
BotPresenceConfigSchema,
FeatureFlagUpsertSchema,
ownerRoleAtLeast,
OwnerTeamUpsertSchema
} from './owner.js';
describe('owner schemas', () => {
it('parses presence config', () => {
const parsed = BotPresenceConfigSchema.parse({
status: 'online',
activityType: 'Playing',
activityText: 'nexumi.de',
rotatingMessages: ['a', 'b'],
maintenanceMode: false,
maintenanceMessage: null
});
expect(parsed.activityText).toBe('nexumi.de');
});
it('ranks owner roles', () => {
expect(ownerRoleAtLeast('ADMIN', 'SUPPORT')).toBe(true);
expect(ownerRoleAtLeast('VIEWER', 'ADMIN')).toBe(false);
});
it('validates team and flags', () => {
expect(
OwnerTeamUpsertSchema.parse({ userId: '123456789012345678', role: 'SUPPORT', note: null }).role
).toBe('SUPPORT');
expect(
FeatureFlagUpsertSchema.parse({
key: 'new.module',
enabled: true,
rolloutPercent: 10,
whitelistGuildIds: []
}).rolloutPercent
).toBe(10);
});
});

View File

@@ -0,0 +1,103 @@
import { z } from 'zod';
const snowflake = z.string().regex(/^\d{17,20}$/);
export const OwnerRoleSchema = z.enum(['VIEWER', 'SUPPORT', 'ADMIN', 'OWNER']);
export type OwnerRole = z.infer<typeof OwnerRoleSchema>;
export const OWNER_ROLE_RANK: Record<OwnerRole, number> = {
VIEWER: 1,
SUPPORT: 2,
ADMIN: 3,
OWNER: 4
};
export function ownerRoleAtLeast(role: OwnerRole, minimum: OwnerRole): boolean {
return OWNER_ROLE_RANK[role] >= OWNER_ROLE_RANK[minimum];
}
export const BotPresenceStatusSchema = z.enum(['online', 'idle', 'dnd', 'invisible']);
export const BotActivityTypeSchema = z.enum(['Playing', 'Listening', 'Watching', 'Competing']);
export const BotPresenceConfigSchema = z.object({
status: BotPresenceStatusSchema,
activityType: BotActivityTypeSchema,
activityText: z.string().min(1).max(128),
rotatingMessages: z.array(z.string().min(1).max(128)).max(20),
maintenanceMode: z.boolean(),
maintenanceMessage: z.string().max(500).nullable()
});
export type BotPresenceConfig = z.infer<typeof BotPresenceConfigSchema>;
export const BotPresenceConfigPatchSchema = BotPresenceConfigSchema.partial().refine(
(value) => Object.keys(value).length > 0,
{ message: 'At least one field is required' }
);
export const OwnerTeamMemberSchema = z.object({
id: z.string().optional(),
userId: snowflake,
role: OwnerRoleSchema,
note: z.string().max(500).nullable().optional()
});
export type OwnerTeamMember = z.infer<typeof OwnerTeamMemberSchema>;
export const OwnerTeamUpsertSchema = OwnerTeamMemberSchema;
export const FeatureFlagSchema = z.object({
id: z.string().optional(),
key: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9._-]+$/i),
enabled: z.boolean(),
rolloutPercent: z.number().int().min(0).max(100),
whitelistGuildIds: z.array(snowflake)
});
export type FeatureFlag = z.infer<typeof FeatureFlagSchema>;
export const FeatureFlagUpsertSchema = FeatureFlagSchema;
export const GlobalUserBlacklistSchema = z.object({
id: z.string().optional(),
userId: snowflake,
reason: z.string().max(500).nullable().optional(),
note: z.string().max(1000).nullable().optional()
});
export type GlobalUserBlacklist = z.infer<typeof GlobalUserBlacklistSchema>;
export const GuildBlacklistSchema = z.object({
id: z.string().optional(),
guildId: snowflake,
reason: z.string().max(500).nullable().optional()
});
export type GuildBlacklist = z.infer<typeof GuildBlacklistSchema>;
export const ChangelogEntrySchema = z.object({
id: z.string().optional(),
version: z.string().min(1).max(32),
title: z.string().min(1).max(200),
body: z.string().min(1).max(10000),
publishedAt: z.string().datetime().optional()
});
export type ChangelogEntry = z.infer<typeof ChangelogEntrySchema>;
export const OwnerAuditEntrySchema = z.object({
id: z.string(),
actorUserId: z.string(),
action: z.string(),
targetType: z.string().nullable(),
targetId: z.string().nullable(),
createdAt: z.string()
});
export type OwnerAuditEntry = z.infer<typeof OwnerAuditEntrySchema>;
export const PRESENCE_REDIS_KEY = 'bot:presence';