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:
@@ -8,6 +8,7 @@ import { t } from '@nexumi/shared';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
|
import { isMaintenanceMode } from './presence.js';
|
||||||
import { moderationCommands } from './modules/moderation/commands.js';
|
import { moderationCommands } from './modules/moderation/commands.js';
|
||||||
import { automodCommands } from './modules/automod/commands.js';
|
import { automodCommands } from './modules/automod/commands.js';
|
||||||
import { welcomeCommands } from './modules/welcome/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) {
|
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
||||||
const command = map.get(interaction.commandName);
|
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
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) {
|
if (!command) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -22,7 +22,21 @@ const EnvSchema = z.object({
|
|||||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
||||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
||||||
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
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);
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ if (!isShard) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Failed to register application commands');
|
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) => {
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { pollAllFeeds } from './modules/feeds/index.js';
|
|||||||
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
||||||
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||||
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
||||||
|
import { runPresenceRefreshJob } from './presence.js';
|
||||||
import {
|
import {
|
||||||
automodQueue,
|
automodQueue,
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
giveawayQueueName,
|
giveawayQueueName,
|
||||||
guildBackupQueueName,
|
guildBackupQueueName,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
|
presenceQueue,
|
||||||
|
presenceQueueName,
|
||||||
reminderQueueName,
|
reminderQueueName,
|
||||||
scheduleQueueName,
|
scheduleQueueName,
|
||||||
suggestionsQueueName,
|
suggestionsQueueName,
|
||||||
@@ -304,6 +307,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed');
|
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 [
|
return [
|
||||||
moderationWorker,
|
moderationWorker,
|
||||||
backupWorker,
|
backupWorker,
|
||||||
@@ -317,7 +334,8 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
feedsWorker,
|
feedsWorker,
|
||||||
scheduleWorker,
|
scheduleWorker,
|
||||||
guildBackupWorker,
|
guildBackupWorker,
|
||||||
suggestionsWorker
|
suggestionsWorker,
|
||||||
|
presenceWorker
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +393,16 @@ export async function ensureRecurringJobs(): Promise<void> {
|
|||||||
removeOnFail: 20
|
removeOnFail: 20
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await presenceQueue.add(
|
||||||
|
'presenceRefresh',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { every: 60_000 },
|
||||||
|
removeOnComplete: 5,
|
||||||
|
removeOnFail: 5
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPgDumpBackup(): Promise<void> {
|
async function runPgDumpBackup(): Promise<void> {
|
||||||
|
|||||||
78
apps/bot/src/presence.ts
Normal file
78
apps/bot/src/presence.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,3 +27,5 @@ export const guildBackupQueueName = 'guild-backups';
|
|||||||
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
||||||
export const suggestionsQueueName = 'suggestions';
|
export const suggestionsQueueName = 'suggestions';
|
||||||
export const suggestionsQueue = new Queue(suggestionsQueueName, { connection: redis });
|
export const suggestionsQueue = new Queue(suggestionsQueueName, { connection: redis });
|
||||||
|
export const presenceQueueName = 'presence';
|
||||||
|
export const presenceQueue = new Queue(presenceQueueName, { connection: redis });
|
||||||
|
|||||||
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100);
|
||||||
|
const offset = Math.max(Number(searchParams.get('offset') ?? 0), 0);
|
||||||
|
const entries = await listOwnerAudit(limit, offset);
|
||||||
|
return NextResponse.json({ entries });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { ChangelogEntrySchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const entries = await prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } });
|
||||||
|
return NextResponse.json({
|
||||||
|
entries: entries.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
publishedAt: entry.publishedAt.toISOString(),
|
||||||
|
createdAt: entry.createdAt.toISOString(),
|
||||||
|
updatedAt: entry.updatedAt.toISOString()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const body = ChangelogEntrySchema.parse(await request.json());
|
||||||
|
const entry = await prisma.changelogEntry.create({
|
||||||
|
data: {
|
||||||
|
version: body.version,
|
||||||
|
title: body.title,
|
||||||
|
body: body.body,
|
||||||
|
publishedAt: body.publishedAt ? new Date(body.publishedAt) : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'changelog.create',
|
||||||
|
targetType: 'ChangelogEntry',
|
||||||
|
targetId: entry.id,
|
||||||
|
after: entry
|
||||||
|
});
|
||||||
|
return NextResponse.json(entry);
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const id = new URL(request.url).searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const before = await prisma.changelogEntry.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await prisma.changelogEntry.delete({ where: { id } });
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'changelog.delete',
|
||||||
|
targetType: 'ChangelogEntry',
|
||||||
|
targetId: id,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { FeatureFlagUpsertSchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const flags = await prisma.featureFlag.findMany({ orderBy: { key: 'asc' } });
|
||||||
|
return NextResponse.json({ flags });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const body = FeatureFlagUpsertSchema.parse(await request.json());
|
||||||
|
const before = await prisma.featureFlag.findUnique({ where: { key: body.key } });
|
||||||
|
const flag = await prisma.featureFlag.upsert({
|
||||||
|
where: { key: body.key },
|
||||||
|
create: {
|
||||||
|
key: body.key,
|
||||||
|
enabled: body.enabled,
|
||||||
|
rolloutPercent: body.rolloutPercent,
|
||||||
|
whitelistGuildIds: body.whitelistGuildIds
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
enabled: body.enabled,
|
||||||
|
rolloutPercent: body.rolloutPercent,
|
||||||
|
whitelistGuildIds: body.whitelistGuildIds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: before ? 'flags.update' : 'flags.create',
|
||||||
|
targetType: 'FeatureFlag',
|
||||||
|
targetId: flag.id,
|
||||||
|
before,
|
||||||
|
after: flag
|
||||||
|
});
|
||||||
|
return NextResponse.json(flag);
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const key = new URL(request.url).searchParams.get('key');
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json({ error: 'key required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const before = await prisma.featureFlag.findUnique({ where: { key } });
|
||||||
|
if (!before) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await prisma.featureFlag.delete({ where: { key } });
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'flags.delete',
|
||||||
|
targetType: 'FeatureFlag',
|
||||||
|
targetId: before.id,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { GuildBlacklistSchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
|
||||||
|
const guilds = await prisma.guild.findMany({
|
||||||
|
where: q ? { id: { contains: q } } : undefined,
|
||||||
|
include: { settings: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100
|
||||||
|
});
|
||||||
|
const blacklist = await prisma.guildBlacklist.findMany();
|
||||||
|
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||||
|
return NextResponse.json({
|
||||||
|
guilds: guilds.map((guild) => ({
|
||||||
|
id: guild.id,
|
||||||
|
locale: guild.settings?.locale ?? 'de',
|
||||||
|
createdAt: guild.createdAt.toISOString(),
|
||||||
|
blacklisted: blacklisted.has(guild.id)
|
||||||
|
})),
|
||||||
|
blacklist
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
||||||
|
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'leave') {
|
||||||
|
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bot ${env.BOT_TOKEN}` }
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
return NextResponse.json({ error: `Discord leave failed (${response.status})` }, { status: 502 });
|
||||||
|
}
|
||||||
|
await prisma.guild.delete({ where: { id: body.guildId } }).catch(() => undefined);
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'guilds.leave',
|
||||||
|
targetType: 'Guild',
|
||||||
|
targetId: body.guildId
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'blacklist') {
|
||||||
|
const parsed = GuildBlacklistSchema.parse({
|
||||||
|
guildId: body.guildId,
|
||||||
|
reason: body.reason ?? null
|
||||||
|
});
|
||||||
|
const row = await prisma.guildBlacklist.upsert({
|
||||||
|
where: { guildId: parsed.guildId },
|
||||||
|
create: {
|
||||||
|
guildId: parsed.guildId,
|
||||||
|
reason: parsed.reason ?? null,
|
||||||
|
createdById: session.user.id
|
||||||
|
},
|
||||||
|
update: { reason: parsed.reason ?? null }
|
||||||
|
});
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'guilds.blacklist',
|
||||||
|
targetType: 'GuildBlacklist',
|
||||||
|
targetId: row.id,
|
||||||
|
after: row
|
||||||
|
});
|
||||||
|
return NextResponse.json(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'unblacklist') {
|
||||||
|
const before = await prisma.guildBlacklist.findUnique({ where: { guildId: body.guildId } });
|
||||||
|
if (before) {
|
||||||
|
await prisma.guildBlacklist.delete({ where: { guildId: body.guildId } });
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'guilds.unblacklist',
|
||||||
|
targetType: 'GuildBlacklist',
|
||||||
|
targetId: before.id,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { getAllQueueStats, retryFailedJobs } from '@/lib/owner-jobs';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('SUPPORT');
|
||||||
|
const [queues, migrations] = await Promise.all([
|
||||||
|
getAllQueueStats(),
|
||||||
|
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
|
||||||
|
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
|
||||||
|
`
|
||||||
|
]);
|
||||||
|
return NextResponse.json({
|
||||||
|
queues,
|
||||||
|
migrations: migrations.map((row) => ({
|
||||||
|
name: row.migration_name,
|
||||||
|
finishedAt: row.finished_at?.toISOString() ?? null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const body = (await request.json()) as { queueName?: string };
|
||||||
|
if (!body.queueName) {
|
||||||
|
return NextResponse.json({ error: 'queueName required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const retried = await retryFailedJobs(body.queueName);
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'jobs.retryFailed',
|
||||||
|
targetType: 'Queue',
|
||||||
|
targetId: body.queueName,
|
||||||
|
after: { retried }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ retried });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.guild.count(),
|
||||||
|
prisma.globalUserBlacklist.count(),
|
||||||
|
prisma.guildBlacklist.count(),
|
||||||
|
getPresenceConfig(),
|
||||||
|
listOwnerAudit(10),
|
||||||
|
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
guildCount,
|
||||||
|
blacklistUsers,
|
||||||
|
blacklistGuilds,
|
||||||
|
uptimeSeconds: Math.floor(process.uptime()),
|
||||||
|
maintenanceMode: presence.maintenanceMode,
|
||||||
|
recentAudit: audit,
|
||||||
|
changelog: changelog.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
version: entry.version,
|
||||||
|
title: entry.title,
|
||||||
|
publishedAt: entry.publishedAt.toISOString()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { BotPresenceConfigPatchSchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { getPresenceConfig, updatePresenceConfig } from '@/lib/owner-presence';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
return NextResponse.json(await getPresenceConfig());
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const body = BotPresenceConfigPatchSchema.parse(await request.json());
|
||||||
|
const before = await getPresenceConfig();
|
||||||
|
const after = await updatePresenceConfig(body);
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'presence.update',
|
||||||
|
targetType: 'BotPresenceConfig',
|
||||||
|
targetId: 'singleton',
|
||||||
|
before,
|
||||||
|
after
|
||||||
|
});
|
||||||
|
return NextResponse.json(after);
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { OwnerTeamUpsertSchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const members = await prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } });
|
||||||
|
return NextResponse.json({
|
||||||
|
bootstrapOwnerIds: env.OWNER_USER_IDS ?? [],
|
||||||
|
members
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('OWNER');
|
||||||
|
const body = OwnerTeamUpsertSchema.parse(await request.json());
|
||||||
|
const before = await prisma.ownerTeamMember.findUnique({ where: { userId: body.userId } });
|
||||||
|
const member = await prisma.ownerTeamMember.upsert({
|
||||||
|
where: { userId: body.userId },
|
||||||
|
create: {
|
||||||
|
userId: body.userId,
|
||||||
|
role: body.role,
|
||||||
|
note: body.note ?? null
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
role: body.role,
|
||||||
|
note: body.note ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: before ? 'team.update' : 'team.create',
|
||||||
|
targetType: 'OwnerTeamMember',
|
||||||
|
targetId: member.id,
|
||||||
|
before,
|
||||||
|
after: member
|
||||||
|
});
|
||||||
|
return NextResponse.json(member);
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('OWNER');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const before = await prisma.ownerTeamMember.findUnique({ where: { userId } });
|
||||||
|
if (!before) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await prisma.ownerTeamMember.delete({ where: { userId } });
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'team.delete',
|
||||||
|
targetType: 'OwnerTeamMember',
|
||||||
|
targetId: before.id,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { GlobalUserBlacklistSchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
const users = await prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('SUPPORT');
|
||||||
|
const body = GlobalUserBlacklistSchema.parse(await request.json());
|
||||||
|
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId: body.userId } });
|
||||||
|
const row = await prisma.globalUserBlacklist.upsert({
|
||||||
|
where: { userId: body.userId },
|
||||||
|
create: {
|
||||||
|
userId: body.userId,
|
||||||
|
reason: body.reason ?? null,
|
||||||
|
note: body.note ?? null,
|
||||||
|
createdById: session.user.id
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
reason: body.reason ?? null,
|
||||||
|
note: body.note ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: before ? 'users.blacklist.update' : 'users.blacklist.create',
|
||||||
|
targetType: 'GlobalUserBlacklist',
|
||||||
|
targetId: row.id,
|
||||||
|
before,
|
||||||
|
after: row
|
||||||
|
});
|
||||||
|
return NextResponse.json(row);
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('SUPPORT');
|
||||||
|
const userId = new URL(request.url).searchParams.get('userId');
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId } });
|
||||||
|
if (!before) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await prisma.globalUserBlacklist.delete({ where: { userId } });
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'users.blacklist.delete',
|
||||||
|
targetType: 'GlobalUserBlacklist',
|
||||||
|
targetId: before.id,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||||
import { getManageableGuilds } from '@/lib/guilds';
|
import { getManageableGuilds } from '@/lib/guilds';
|
||||||
|
import { isOwnerUser } from '@/lib/owner-auth';
|
||||||
|
|
||||||
interface GuildLayoutProps {
|
interface GuildLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -20,9 +21,16 @@ export default async function GuildLayout({ children, params }: GuildLayoutProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
|
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
|
||||||
|
const showOwnerLink = await isOwnerUser(session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell guildId={guildId} currentGuild={currentGuild} otherGuilds={otherGuilds} user={session.user}>
|
<DashboardShell
|
||||||
|
guildId={guildId}
|
||||||
|
currentGuild={currentGuild}
|
||||||
|
otherGuilds={otherGuilds}
|
||||||
|
user={session.user}
|
||||||
|
showOwnerLink={showOwnerLink}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
|
|||||||
37
apps/webui/src/app/owner/audit/page.tsx
Normal file
37
apps/webui/src/app/owner/audit/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
|
||||||
|
export default async function OwnerAuditPage() {
|
||||||
|
const [locale, entries] = await Promise.all([getLocale(), listOwnerAudit(100)]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.audit.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.audit.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="divide-y divide-border p-0">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">{t(locale, 'owner.common.empty')}</p>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex flex-col gap-1 px-4 py-3 text-sm sm:flex-row sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{entry.action}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{entry.actorUserId}
|
||||||
|
{entry.targetType ? ` · ${entry.targetType}` : ''}
|
||||||
|
{entry.targetId ? ` · ${entry.targetId}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/webui/src/app/owner/changelog/page.tsx
Normal file
28
apps/webui/src/app/owner/changelog/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ChangelogManager } from '@/components/owner/owner-forms';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerChangelogPage() {
|
||||||
|
const [locale, entries] = await Promise.all([
|
||||||
|
getLocale(),
|
||||||
|
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.changelog.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.changelog.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<ChangelogManager
|
||||||
|
initialEntries={entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
version: entry.version,
|
||||||
|
title: entry.title,
|
||||||
|
body: entry.body,
|
||||||
|
publishedAt: entry.publishedAt.toISOString()
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/webui/src/app/owner/flags/page.tsx
Normal file
20
apps/webui/src/app/owner/flags/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { FlagsManager } from '@/components/owner/owner-forms';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerFlagsPage() {
|
||||||
|
const [locale, flags] = await Promise.all([
|
||||||
|
getLocale(),
|
||||||
|
prisma.featureFlag.findMany({ orderBy: { key: 'asc' } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.flags.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.flags.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<FlagsManager initialFlags={flags} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/webui/src/app/owner/guilds/page.tsx
Normal file
33
apps/webui/src/app/owner/guilds/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { GuildsManager } from '@/components/owner/owner-forms';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerGuildsPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const [guilds, blacklist] = await Promise.all([
|
||||||
|
prisma.guild.findMany({
|
||||||
|
include: { settings: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200
|
||||||
|
}),
|
||||||
|
prisma.guildBlacklist.findMany()
|
||||||
|
]);
|
||||||
|
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<GuildsManager
|
||||||
|
initialGuilds={guilds.map((guild) => ({
|
||||||
|
id: guild.id,
|
||||||
|
locale: guild.settings?.locale ?? 'de',
|
||||||
|
createdAt: guild.createdAt.toISOString(),
|
||||||
|
blacklisted: blacklisted.has(guild.id)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/webui/src/app/owner/jobs/page.tsx
Normal file
30
apps/webui/src/app/owner/jobs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { JobsManager } from '@/components/owner/owner-forms';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { getAllQueueStats } from '@/lib/owner-jobs';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerJobsPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const [queues, migrations] = await Promise.all([
|
||||||
|
getAllQueueStats(),
|
||||||
|
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
|
||||||
|
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
|
||||||
|
`
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.jobs.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.jobs.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<JobsManager
|
||||||
|
initialQueues={queues}
|
||||||
|
initialMigrations={migrations.map((row) => ({
|
||||||
|
name: row.migration_name,
|
||||||
|
finishedAt: row.finished_at?.toISOString() ?? null
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/webui/src/app/owner/layout.tsx
Normal file
7
apps/webui/src/app/owner/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { OwnerShell } from '@/components/owner/owner-shell';
|
||||||
|
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||||
|
|
||||||
|
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await requireOwnerOrRedirect('VIEWER');
|
||||||
|
return <OwnerShell user={session.user}>{children}</OwnerShell>;
|
||||||
|
}
|
||||||
97
apps/webui/src/app/owner/page.tsx
Normal file
97
apps/webui/src/app/owner/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerOverviewPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] = await Promise.all([
|
||||||
|
prisma.guild.count(),
|
||||||
|
prisma.globalUserBlacklist.count(),
|
||||||
|
prisma.guildBlacklist.count(),
|
||||||
|
getPresenceConfig(),
|
||||||
|
listOwnerAudit(10),
|
||||||
|
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: t(locale, 'owner.overview.guilds'), value: String(guildCount) },
|
||||||
|
{ label: t(locale, 'owner.overview.blacklistedUsers'), value: String(blacklistUsers) },
|
||||||
|
{ label: t(locale, 'owner.overview.blacklistedGuilds'), value: String(blacklistGuilds) },
|
||||||
|
{
|
||||||
|
label: t(locale, 'owner.overview.maintenance'),
|
||||||
|
value: presence.maintenanceMode ? t(locale, 'common.yes') : t(locale, 'common.no')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(locale, 'owner.overview.uptime'),
|
||||||
|
value: `${Math.floor(process.uptime() / 60)} min`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.overview.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.overview.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Card key={card.label}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">{card.label}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-semibold">{card.value}</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t(locale, 'owner.overview.recentAudit')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
{audit.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyAudit')}</p>
|
||||||
|
) : (
|
||||||
|
audit.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex justify-between gap-2 border-b border-border/60 py-2 last:border-0">
|
||||||
|
<span>
|
||||||
|
{entry.action}
|
||||||
|
{entry.targetId ? ` · ${entry.targetId}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Link href="/owner/audit" className="text-primary hover:underline">
|
||||||
|
{t(locale, 'owner.overview.viewAudit')}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t(locale, 'owner.overview.changelog')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
{changelog.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyChangelog')}</p>
|
||||||
|
) : (
|
||||||
|
changelog.map((entry) => (
|
||||||
|
<div key={entry.id} className="border-b border-border/60 py-2 last:border-0">
|
||||||
|
<p className="font-medium">
|
||||||
|
{entry.version} — {entry.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Link href="/owner/changelog" className="text-primary hover:underline">
|
||||||
|
{t(locale, 'owner.overview.manageChangelog')}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/webui/src/app/owner/presence/page.tsx
Normal file
16
apps/webui/src/app/owner/presence/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { PresenceForm } from '@/components/owner/owner-forms';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||||
|
|
||||||
|
export default async function OwnerPresencePage() {
|
||||||
|
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<PresenceForm initialValue={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/webui/src/app/owner/team/page.tsx
Normal file
21
apps/webui/src/app/owner/team/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { TeamManager } from '@/components/owner/owner-forms';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerTeamPage() {
|
||||||
|
const [locale, members] = await Promise.all([
|
||||||
|
getLocale(),
|
||||||
|
prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.team.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.team.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<TeamManager bootstrapOwnerIds={env.OWNER_USER_IDS ?? []} initialMembers={members} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/webui/src/app/owner/users/page.tsx
Normal file
20
apps/webui/src/app/owner/users/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { UsersManager } from '@/components/owner/owner-forms';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function OwnerUsersPage() {
|
||||||
|
const [locale, users] = await Promise.all([
|
||||||
|
getLocale(),
|
||||||
|
prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.users.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.users.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<UsersManager initialUsers={users} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,17 +9,25 @@ interface DashboardShellProps {
|
|||||||
currentGuild: DashboardGuild;
|
currentGuild: DashboardGuild;
|
||||||
otherGuilds: DashboardGuild[];
|
otherGuilds: DashboardGuild[];
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
|
showOwnerLink?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) {
|
export function DashboardShell({
|
||||||
|
guildId,
|
||||||
|
currentGuild,
|
||||||
|
otherGuilds,
|
||||||
|
user,
|
||||||
|
showOwnerLink = false,
|
||||||
|
children
|
||||||
|
}: DashboardShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar guildId={guildId} />
|
<Sidebar guildId={guildId} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border px-6">
|
<header className="flex h-14 items-center justify-between border-b border-border px-6">
|
||||||
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
||||||
<UserMenu user={user} />
|
<UserMenu user={user} showOwnerLink={showOwnerLink} />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 px-6 py-8">
|
<main className="flex-1 px-6 py-8">
|
||||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { SessionUser } from '@nexumi/shared';
|
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 { useTheme } from 'next-themes';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -26,7 +26,7 @@ function initials(user: SessionUser): string {
|
|||||||
return name.slice(0, 2).toUpperCase();
|
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 t = useTranslations();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -58,6 +58,19 @@ export function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
|
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{showOwnerLink ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push('/owner');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield className="size-4" />
|
||||||
|
{t('userMenu.ownerPanel')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
||||||
|
|||||||
665
apps/webui/src/components/owner/owner-forms.tsx
Normal file
665
apps/webui/src/components/owner/owner-forms.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/webui/src/components/owner/owner-shell.tsx
Normal file
26
apps/webui/src/components/owner/owner-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/webui/src/components/owner/owner-sidebar.tsx
Normal file
75
apps/webui/src/components/owner/owner-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/webui/src/lib/owner-audit.ts
Normal file
51
apps/webui/src/lib/owner-audit.ts
Normal 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()
|
||||||
|
}));
|
||||||
|
}
|
||||||
55
apps/webui/src/lib/owner-auth.ts
Normal file
55
apps/webui/src/lib/owner-auth.ts
Normal 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 };
|
||||||
76
apps/webui/src/lib/owner-jobs.ts
Normal file
76
apps/webui/src/lib/owner-jobs.ts
Normal 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;
|
||||||
|
}
|
||||||
67
apps/webui/src/lib/owner-presence.ts
Normal file
67
apps/webui/src/lib/owner-presence.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -518,7 +518,97 @@
|
|||||||
"themeDark": "Dunkel",
|
"themeDark": "Dunkel",
|
||||||
"themeLight": "Hell",
|
"themeLight": "Hell",
|
||||||
"logout": "Abmelden",
|
"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": {
|
"serverSwitcher": {
|
||||||
"switchServer": "Server wechseln",
|
"switchServer": "Server wechseln",
|
||||||
|
|||||||
@@ -518,7 +518,97 @@
|
|||||||
"themeDark": "Dark",
|
"themeDark": "Dark",
|
||||||
"themeLight": "Light",
|
"themeLight": "Light",
|
||||||
"logout": "Log out",
|
"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": {
|
"serverSwitcher": {
|
||||||
"switchServer": "Switch server",
|
"switchServer": "Switch server",
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ export function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/dashboard/:path*']
|
matcher: ['/dashboard/:path*', '/owner/:path*']
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +1,25 @@
|
|||||||
## Phase 6 – WebUI-Fundament (Status: abgeschlossen, manuell bestätigt)
|
## 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
|
### Abgeschlossen
|
||||||
|
|
||||||
- `apps/webui` als `@nexumi/webui` (Next.js 15 App Router, Tailwind 4, React 19)
|
- **Batch A:** Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun + Prisma Owner-Modelle (`20260722190000_phase7_owner`)
|
||||||
- Discord-OAuth2 + Redis-Sessions; Cookie-Fix für HTTP (`Secure` nur bei HTTPS; Set-Cookie am Redirect-Response)
|
- **Batch B:** Tickets, Giveaways, SelfRoles, Tags, Starboard, Suggestions, Birthdays, TempVoice, Stats, Feeds, Scheduler, Backup, Commands-Seite, Overview-Aktivität; BullMQ-Anbindung WebUI↔Bot
|
||||||
- Settings-Framework, Layout, Shared Zod-API, Modul-Toggles, Access-Rules, Dashboard-Audit
|
- **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`
|
||||||
- Deploy: bot+webui healthy; Login manuell bestätigt (`WEBUI_URL=http://10.111.0.65:3000`)
|
- 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)
|
1. `OWNER_USER_IDS=<deine Discord-User-ID>` in `.env` setzen, Stack neu starten
|
||||||
|
2. Dashboard-Modul-Seiten speichern (Settings/Module/Cases etc.)
|
||||||
- Prisma-Grundlage für Owner-Features: `CommandOverride`, `OwnerTeamMember`, `GlobalUserBlacklist`, `GuildBlacklist`, `FeatureFlag`, `BotPresenceConfig`, `ChangelogEntry`, `OwnerAuditLog` inkl. Migration `20260722190000_phase7_owner`
|
3. Owner-Panel: `/owner` → Presence, Flags, Team, Jobs
|
||||||
- Shared Zod-Schemas für Dashboard-Module in `packages/shared/src/dashboard.ts` (Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun, Cases, Warnings) + Tests
|
4. Giveaway/Suggestion/Backup-Aktionen aus dem Dashboard (BullMQ)
|
||||||
- `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
|
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## 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).
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from './phase4.js';
|
|||||||
export * from './phase5.js';
|
export * from './phase5.js';
|
||||||
export * from './phase6.js';
|
export * from './phase6.js';
|
||||||
export * from './dashboard.js';
|
export * from './dashboard.js';
|
||||||
|
export * from './owner.js';
|
||||||
|
|
||||||
export const LocaleSchema = z.enum(['de', 'en']);
|
export const LocaleSchema = z.enum(['de', 'en']);
|
||||||
export type Locale = z.infer<typeof LocaleSchema>;
|
export type Locale = z.infer<typeof LocaleSchema>;
|
||||||
@@ -49,6 +50,7 @@ const de: Dictionary = {
|
|||||||
'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).',
|
'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).',
|
||||||
'generic.invalidRegex': 'Ungültiges Regex-Muster.',
|
'generic.invalidRegex': 'Ungültiges Regex-Muster.',
|
||||||
'generic.error': 'Es ist ein Fehler aufgetreten.',
|
'generic.error': 'Es ist ein Fehler aufgetreten.',
|
||||||
|
'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.',
|
||||||
'generic.unknownCommand': 'Unbekannter Befehl.',
|
'generic.unknownCommand': 'Unbekannter Befehl.',
|
||||||
'moderation.success': 'Aktion erfolgreich ausgeführt.',
|
'moderation.success': 'Aktion erfolgreich ausgeführt.',
|
||||||
'moderation.reason.none': 'Kein Grund angegeben',
|
'moderation.reason.none': 'Kein Grund angegeben',
|
||||||
@@ -523,6 +525,7 @@ const en: Dictionary = {
|
|||||||
'generic.botMissingPermission': 'Bot lacks required permission ({permission}).',
|
'generic.botMissingPermission': 'Bot lacks required permission ({permission}).',
|
||||||
'generic.invalidRegex': 'Invalid regex pattern.',
|
'generic.invalidRegex': 'Invalid regex pattern.',
|
||||||
'generic.error': 'An error occurred.',
|
'generic.error': 'An error occurred.',
|
||||||
|
'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.',
|
||||||
'generic.unknownCommand': 'Unknown command.',
|
'generic.unknownCommand': 'Unknown command.',
|
||||||
'moderation.success': 'Action executed successfully.',
|
'moderation.success': 'Action executed successfully.',
|
||||||
'moderation.reason.none': 'No reason provided',
|
'moderation.reason.none': 'No reason provided',
|
||||||
|
|||||||
40
packages/shared/src/owner.test.ts
Normal file
40
packages/shared/src/owner.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
packages/shared/src/owner.ts
Normal file
103
packages/shared/src/owner.ts
Normal 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';
|
||||||
Reference in New Issue
Block a user