diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 351b8db..4855daa 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -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; diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index 5311677..82ce65d 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -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); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index c52dbf3..cdf6dbe 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -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) => { diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index c663e21..5707e5a 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -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 { removeOnFail: 20 } ); + + await presenceQueue.add( + 'presenceRefresh', + {}, + { + repeat: { every: 60_000 }, + removeOnComplete: 5, + removeOnFail: 5 + } + ); } async function runPgDumpBackup(): Promise { diff --git a/apps/bot/src/presence.ts b/apps/bot/src/presence.ts new file mode 100644 index 0000000..03eef53 --- /dev/null +++ b/apps/bot/src/presence.ts @@ -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 = { + Playing: ActivityType.Playing, + Listening: ActivityType.Listening, + Watching: ActivityType.Watching, + Competing: ActivityType.Competing +}; + +export async function readPresenceConfig(context: BotContext): Promise { + 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 { + 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 { + try { + await applyBotPresence(context); + } catch (error) { + logger.warn({ error }, 'Failed to apply bot presence'); + } +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 904d1dd..88fe4c4 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -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 }); diff --git a/apps/webui/src/app/api/owner/audit/route.ts b/apps/webui/src/app/api/owner/audit/route.ts new file mode 100644 index 0000000..1c90baf --- /dev/null +++ b/apps/webui/src/app/api/owner/audit/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/changelog/route.ts b/apps/webui/src/app/api/owner/changelog/route.ts new file mode 100644 index 0000000..ff1c5cc --- /dev/null +++ b/apps/webui/src/app/api/owner/changelog/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/flags/route.ts b/apps/webui/src/app/api/owner/flags/route.ts new file mode 100644 index 0000000..34cd7fe --- /dev/null +++ b/apps/webui/src/app/api/owner/flags/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/guilds/route.ts b/apps/webui/src/app/api/owner/guilds/route.ts new file mode 100644 index 0000000..d582ff4 --- /dev/null +++ b/apps/webui/src/app/api/owner/guilds/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/jobs/route.ts b/apps/webui/src/app/api/owner/jobs/route.ts new file mode 100644 index 0000000..0539f49 --- /dev/null +++ b/apps/webui/src/app/api/owner/jobs/route.ts @@ -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>` + 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); + } +} diff --git a/apps/webui/src/app/api/owner/overview/route.ts b/apps/webui/src/app/api/owner/overview/route.ts new file mode 100644 index 0000000..34f34ba --- /dev/null +++ b/apps/webui/src/app/api/owner/overview/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/presence/route.ts b/apps/webui/src/app/api/owner/presence/route.ts new file mode 100644 index 0000000..9c22a7c --- /dev/null +++ b/apps/webui/src/app/api/owner/presence/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/team/route.ts b/apps/webui/src/app/api/owner/team/route.ts new file mode 100644 index 0000000..deb8140 --- /dev/null +++ b/apps/webui/src/app/api/owner/team/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/owner/users/route.ts b/apps/webui/src/app/api/owner/users/route.ts new file mode 100644 index 0000000..60bbf50 --- /dev/null +++ b/apps/webui/src/app/api/owner/users/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/layout.tsx b/apps/webui/src/app/dashboard/[guildId]/layout.tsx index c5a4048..ade1c86 100644 --- a/apps/webui/src/app/dashboard/[guildId]/layout.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/layout.tsx @@ -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 ( - + {children} ); diff --git a/apps/webui/src/app/owner/audit/page.tsx b/apps/webui/src/app/owner/audit/page.tsx new file mode 100644 index 0000000..0b4dacc --- /dev/null +++ b/apps/webui/src/app/owner/audit/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.audit.title')}

+

{t(locale, 'owner.audit.subtitle')}

+
+ + + {entries.length === 0 ? ( +

{t(locale, 'owner.common.empty')}

+ ) : ( + entries.map((entry) => ( +
+
+

{entry.action}

+

+ {entry.actorUserId} + {entry.targetType ? ` · ${entry.targetType}` : ''} + {entry.targetId ? ` · ${entry.targetId}` : ''} +

+
+ {new Date(entry.createdAt).toLocaleString()} +
+ )) + )} +
+
+
+ ); +} diff --git a/apps/webui/src/app/owner/changelog/page.tsx b/apps/webui/src/app/owner/changelog/page.tsx new file mode 100644 index 0000000..6a1fe4e --- /dev/null +++ b/apps/webui/src/app/owner/changelog/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.changelog.title')}

+

{t(locale, 'owner.changelog.subtitle')}

+
+ ({ + id: entry.id, + version: entry.version, + title: entry.title, + body: entry.body, + publishedAt: entry.publishedAt.toISOString() + }))} + /> +
+ ); +} diff --git a/apps/webui/src/app/owner/flags/page.tsx b/apps/webui/src/app/owner/flags/page.tsx new file mode 100644 index 0000000..9c046f6 --- /dev/null +++ b/apps/webui/src/app/owner/flags/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.flags.title')}

+

{t(locale, 'owner.flags.subtitle')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/owner/guilds/page.tsx b/apps/webui/src/app/owner/guilds/page.tsx new file mode 100644 index 0000000..7055a53 --- /dev/null +++ b/apps/webui/src/app/owner/guilds/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.guilds.title')}

+

{t(locale, 'owner.guilds.subtitle')}

+
+ ({ + id: guild.id, + locale: guild.settings?.locale ?? 'de', + createdAt: guild.createdAt.toISOString(), + blacklisted: blacklisted.has(guild.id) + }))} + /> +
+ ); +} diff --git a/apps/webui/src/app/owner/jobs/page.tsx b/apps/webui/src/app/owner/jobs/page.tsx new file mode 100644 index 0000000..7efd3b2 --- /dev/null +++ b/apps/webui/src/app/owner/jobs/page.tsx @@ -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>` + SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST + ` + ]); + + return ( +
+
+

{t(locale, 'owner.jobs.title')}

+

{t(locale, 'owner.jobs.subtitle')}

+
+ ({ + name: row.migration_name, + finishedAt: row.finished_at?.toISOString() ?? null + }))} + /> +
+ ); +} diff --git a/apps/webui/src/app/owner/layout.tsx b/apps/webui/src/app/owner/layout.tsx new file mode 100644 index 0000000..a5deb47 --- /dev/null +++ b/apps/webui/src/app/owner/layout.tsx @@ -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 {children}; +} diff --git a/apps/webui/src/app/owner/page.tsx b/apps/webui/src/app/owner/page.tsx new file mode 100644 index 0000000..5478be3 --- /dev/null +++ b/apps/webui/src/app/owner/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.overview.title')}

+

{t(locale, 'owner.overview.subtitle')}

+
+
+ {cards.map((card) => ( + + + {card.label} + + {card.value} + + ))} +
+
+ + + {t(locale, 'owner.overview.recentAudit')} + + + {audit.length === 0 ? ( +

{t(locale, 'owner.overview.emptyAudit')}

+ ) : ( + audit.map((entry) => ( +
+ + {entry.action} + {entry.targetId ? ` · ${entry.targetId}` : ''} + + {new Date(entry.createdAt).toLocaleString()} +
+ )) + )} + + {t(locale, 'owner.overview.viewAudit')} + +
+
+ + + {t(locale, 'owner.overview.changelog')} + + + {changelog.length === 0 ? ( +

{t(locale, 'owner.overview.emptyChangelog')}

+ ) : ( + changelog.map((entry) => ( +
+

+ {entry.version} — {entry.title} +

+
+ )) + )} + + {t(locale, 'owner.overview.manageChangelog')} + +
+
+
+
+ ); +} diff --git a/apps/webui/src/app/owner/presence/page.tsx b/apps/webui/src/app/owner/presence/page.tsx new file mode 100644 index 0000000..b2a8246 --- /dev/null +++ b/apps/webui/src/app/owner/presence/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.presence.title')}

+

{t(locale, 'owner.presence.subtitle')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/owner/team/page.tsx b/apps/webui/src/app/owner/team/page.tsx new file mode 100644 index 0000000..7139212 --- /dev/null +++ b/apps/webui/src/app/owner/team/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.team.title')}

+

{t(locale, 'owner.team.subtitle')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/owner/users/page.tsx b/apps/webui/src/app/owner/users/page.tsx new file mode 100644 index 0000000..a931833 --- /dev/null +++ b/apps/webui/src/app/owner/users/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.users.title')}

+

{t(locale, 'owner.users.subtitle')}

+
+ +
+ ); +} diff --git a/apps/webui/src/components/layout/dashboard-shell.tsx b/apps/webui/src/components/layout/dashboard-shell.tsx index e98fa0e..c0e1746 100644 --- a/apps/webui/src/components/layout/dashboard-shell.tsx +++ b/apps/webui/src/components/layout/dashboard-shell.tsx @@ -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 (
- +
{children}
diff --git a/apps/webui/src/components/layout/user-menu.tsx b/apps/webui/src/components/layout/user-menu.tsx index 19071b2..c8f22df 100644 --- a/apps/webui/src/components/layout/user-menu.tsx +++ b/apps/webui/src/components/layout/user-menu.tsx @@ -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 }) { {user.globalName ?? user.username} + {showOwnerLink ? ( + <> + { + router.push('/owner'); + }} + > + + {t('userMenu.ownerPanel')} + + + + ) : null} setTheme(theme === 'dark' ? 'light' : 'dark')}> {theme === 'dark' ? : } {theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')} diff --git a/apps/webui/src/components/owner/owner-forms.tsx b/apps/webui/src/components/owner/owner-forms.tsx new file mode 100644 index 0000000..2d5907f --- /dev/null +++ b/apps/webui/src/components/owner/owner-forms.tsx @@ -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 { + 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 ( + { + 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 }) => ( + + + {error &&

{error}

} +
+
+ + +
+
+ + +
+
+
+ + setValue({ ...value, activityText: e.target.value })} /> +
+
+ +