Implement Owner Panel features and maintenance mode handling
- Introduced the Owner Panel with new routes and UI components for managing guilds, user blacklists, feature flags, and bot presence. - Added maintenance mode functionality to prevent non-owner users from executing commands during maintenance periods. - Enhanced localization with new keys for Owner Panel features in both English and German. - Updated job processing to include a presence refresh job, ensuring the bot's status is regularly updated. - Improved environment configuration to support owner user IDs for access control.
This commit is contained in:
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100);
|
||||
const offset = Math.max(Number(searchParams.get('offset') ?? 0), 0);
|
||||
const entries = await listOwnerAudit(limit, offset);
|
||||
return NextResponse.json({ entries });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ChangelogEntrySchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const entries = await prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } });
|
||||
return NextResponse.json({
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
publishedAt: entry.publishedAt.toISOString(),
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
updatedAt: entry.updatedAt.toISOString()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = ChangelogEntrySchema.parse(await request.json());
|
||||
const entry = await prisma.changelogEntry.create({
|
||||
data: {
|
||||
version: body.version,
|
||||
title: body.title,
|
||||
body: body.body,
|
||||
publishedAt: body.publishedAt ? new Date(body.publishedAt) : undefined
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'changelog.create',
|
||||
targetType: 'ChangelogEntry',
|
||||
targetId: entry.id,
|
||||
after: entry
|
||||
});
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const id = new URL(request.url).searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.changelogEntry.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.changelogEntry.delete({ where: { id } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'changelog.delete',
|
||||
targetType: 'ChangelogEntry',
|
||||
targetId: id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { FeatureFlagUpsertSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const flags = await prisma.featureFlag.findMany({ orderBy: { key: 'asc' } });
|
||||
return NextResponse.json({ flags });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = FeatureFlagUpsertSchema.parse(await request.json());
|
||||
const before = await prisma.featureFlag.findUnique({ where: { key: body.key } });
|
||||
const flag = await prisma.featureFlag.upsert({
|
||||
where: { key: body.key },
|
||||
create: {
|
||||
key: body.key,
|
||||
enabled: body.enabled,
|
||||
rolloutPercent: body.rolloutPercent,
|
||||
whitelistGuildIds: body.whitelistGuildIds
|
||||
},
|
||||
update: {
|
||||
enabled: body.enabled,
|
||||
rolloutPercent: body.rolloutPercent,
|
||||
whitelistGuildIds: body.whitelistGuildIds
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: before ? 'flags.update' : 'flags.create',
|
||||
targetType: 'FeatureFlag',
|
||||
targetId: flag.id,
|
||||
before,
|
||||
after: flag
|
||||
});
|
||||
return NextResponse.json(flag);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'key required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.featureFlag.findUnique({ where: { key } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.featureFlag.delete({ where: { key } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'flags.delete',
|
||||
targetType: 'FeatureFlag',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GuildBlacklistSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
|
||||
const guilds = await prisma.guild.findMany({
|
||||
where: q ? { id: { contains: q } } : undefined,
|
||||
include: { settings: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100
|
||||
});
|
||||
const blacklist = await prisma.guildBlacklist.findMany();
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
return NextResponse.json({
|
||||
guilds: guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
})),
|
||||
blacklist
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
||||
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
||||
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'leave') {
|
||||
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bot ${env.BOT_TOKEN}` }
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
return NextResponse.json({ error: `Discord leave failed (${response.status})` }, { status: 502 });
|
||||
}
|
||||
await prisma.guild.delete({ where: { id: body.guildId } }).catch(() => undefined);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.leave',
|
||||
targetType: 'Guild',
|
||||
targetId: body.guildId
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === 'blacklist') {
|
||||
const parsed = GuildBlacklistSchema.parse({
|
||||
guildId: body.guildId,
|
||||
reason: body.reason ?? null
|
||||
});
|
||||
const row = await prisma.guildBlacklist.upsert({
|
||||
where: { guildId: parsed.guildId },
|
||||
create: {
|
||||
guildId: parsed.guildId,
|
||||
reason: parsed.reason ?? null,
|
||||
createdById: session.user.id
|
||||
},
|
||||
update: { reason: parsed.reason ?? null }
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.blacklist',
|
||||
targetType: 'GuildBlacklist',
|
||||
targetId: row.id,
|
||||
after: row
|
||||
});
|
||||
return NextResponse.json(row);
|
||||
}
|
||||
|
||||
if (body.action === 'unblacklist') {
|
||||
const before = await prisma.guildBlacklist.findUnique({ where: { guildId: body.guildId } });
|
||||
if (before) {
|
||||
await prisma.guildBlacklist.delete({ where: { guildId: body.guildId } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.unblacklist',
|
||||
targetType: 'GuildBlacklist',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { getAllQueueStats, retryFailedJobs } from '@/lib/owner-jobs';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('SUPPORT');
|
||||
const [queues, migrations] = await Promise.all([
|
||||
getAllQueueStats(),
|
||||
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
|
||||
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
|
||||
`
|
||||
]);
|
||||
return NextResponse.json({
|
||||
queues,
|
||||
migrations: migrations.map((row) => ({
|
||||
name: row.migration_name,
|
||||
finishedAt: row.finished_at?.toISOString() ?? null
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = (await request.json()) as { queueName?: string };
|
||||
if (!body.queueName) {
|
||||
return NextResponse.json({ error: 'queueName required' }, { status: 400 });
|
||||
}
|
||||
const retried = await retryFailedJobs(body.queueName);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'jobs.retryFailed',
|
||||
targetType: 'Queue',
|
||||
targetId: body.queueName,
|
||||
after: { retried }
|
||||
});
|
||||
return NextResponse.json({ retried });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] =
|
||||
await Promise.all([
|
||||
prisma.guild.count(),
|
||||
prisma.globalUserBlacklist.count(),
|
||||
prisma.guildBlacklist.count(),
|
||||
getPresenceConfig(),
|
||||
listOwnerAudit(10),
|
||||
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
guildCount,
|
||||
blacklistUsers,
|
||||
blacklistGuilds,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
maintenanceMode: presence.maintenanceMode,
|
||||
recentAudit: audit,
|
||||
changelog: changelog.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
title: entry.title,
|
||||
publishedAt: entry.publishedAt.toISOString()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { BotPresenceConfigPatchSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { getPresenceConfig, updatePresenceConfig } from '@/lib/owner-presence';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
return NextResponse.json(await getPresenceConfig());
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = BotPresenceConfigPatchSchema.parse(await request.json());
|
||||
const before = await getPresenceConfig();
|
||||
const after = await updatePresenceConfig(body);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'presence.update',
|
||||
targetType: 'BotPresenceConfig',
|
||||
targetId: 'singleton',
|
||||
before,
|
||||
after
|
||||
});
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { OwnerTeamUpsertSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const members = await prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
return NextResponse.json({
|
||||
bootstrapOwnerIds: env.OWNER_USER_IDS ?? [],
|
||||
members
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('OWNER');
|
||||
const body = OwnerTeamUpsertSchema.parse(await request.json());
|
||||
const before = await prisma.ownerTeamMember.findUnique({ where: { userId: body.userId } });
|
||||
const member = await prisma.ownerTeamMember.upsert({
|
||||
where: { userId: body.userId },
|
||||
create: {
|
||||
userId: body.userId,
|
||||
role: body.role,
|
||||
note: body.note ?? null
|
||||
},
|
||||
update: {
|
||||
role: body.role,
|
||||
note: body.note ?? null
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: before ? 'team.update' : 'team.create',
|
||||
targetType: 'OwnerTeamMember',
|
||||
targetId: member.id,
|
||||
before,
|
||||
after: member
|
||||
});
|
||||
return NextResponse.json(member);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('OWNER');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('userId');
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.ownerTeamMember.findUnique({ where: { userId } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.ownerTeamMember.delete({ where: { userId } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'team.delete',
|
||||
targetType: 'OwnerTeamMember',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GlobalUserBlacklistSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const users = await prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('SUPPORT');
|
||||
const body = GlobalUserBlacklistSchema.parse(await request.json());
|
||||
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId: body.userId } });
|
||||
const row = await prisma.globalUserBlacklist.upsert({
|
||||
where: { userId: body.userId },
|
||||
create: {
|
||||
userId: body.userId,
|
||||
reason: body.reason ?? null,
|
||||
note: body.note ?? null,
|
||||
createdById: session.user.id
|
||||
},
|
||||
update: {
|
||||
reason: body.reason ?? null,
|
||||
note: body.note ?? null
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: before ? 'users.blacklist.update' : 'users.blacklist.create',
|
||||
targetType: 'GlobalUserBlacklist',
|
||||
targetId: row.id,
|
||||
before,
|
||||
after: row
|
||||
});
|
||||
return NextResponse.json(row);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('SUPPORT');
|
||||
const userId = new URL(request.url).searchParams.get('userId');
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.globalUserBlacklist.delete({ where: { userId } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'users.blacklist.delete',
|
||||
targetType: 'GlobalUserBlacklist',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { getManageableGuilds } from '@/lib/guilds';
|
||||
import { isOwnerUser } from '@/lib/owner-auth';
|
||||
|
||||
interface GuildLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -20,9 +21,16 @@ export default async function GuildLayout({ children, params }: GuildLayoutProps
|
||||
}
|
||||
|
||||
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
|
||||
const showOwnerLink = await isOwnerUser(session.user.id);
|
||||
|
||||
return (
|
||||
<DashboardShell guildId={guildId} currentGuild={currentGuild} otherGuilds={otherGuilds} user={session.user}>
|
||||
<DashboardShell
|
||||
guildId={guildId}
|
||||
currentGuild={currentGuild}
|
||||
otherGuilds={otherGuilds}
|
||||
user={session.user}
|
||||
showOwnerLink={showOwnerLink}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
37
apps/webui/src/app/owner/audit/page.tsx
Normal file
37
apps/webui/src/app/owner/audit/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
|
||||
export default async function OwnerAuditPage() {
|
||||
const [locale, entries] = await Promise.all([getLocale(), listOwnerAudit(100)]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.audit.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.audit.subtitle')}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="divide-y divide-border p-0">
|
||||
{entries.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t(locale, 'owner.common.empty')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.id} className="flex flex-col gap-1 px-4 py-3 text-sm sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{entry.action}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{entry.actorUserId}
|
||||
{entry.targetType ? ` · ${entry.targetType}` : ''}
|
||||
{entry.targetId ? ` · ${entry.targetId}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/webui/src/app/owner/changelog/page.tsx
Normal file
28
apps/webui/src/app/owner/changelog/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ChangelogManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerChangelogPage() {
|
||||
const [locale, entries] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.changelog.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.changelog.subtitle')}</p>
|
||||
</div>
|
||||
<ChangelogManager
|
||||
initialEntries={entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
title: entry.title,
|
||||
body: entry.body,
|
||||
publishedAt: entry.publishedAt.toISOString()
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/webui/src/app/owner/flags/page.tsx
Normal file
20
apps/webui/src/app/owner/flags/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FlagsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerFlagsPage() {
|
||||
const [locale, flags] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.featureFlag.findMany({ orderBy: { key: 'asc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.flags.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.flags.subtitle')}</p>
|
||||
</div>
|
||||
<FlagsManager initialFlags={flags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/webui/src/app/owner/guilds/page.tsx
Normal file
33
apps/webui/src/app/owner/guilds/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { GuildsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerGuildsPage() {
|
||||
const locale = await getLocale();
|
||||
const [guilds, blacklist] = await Promise.all([
|
||||
prisma.guild.findMany({
|
||||
include: { settings: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200
|
||||
}),
|
||||
prisma.guildBlacklist.findMany()
|
||||
]);
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
||||
</div>
|
||||
<GuildsManager
|
||||
initialGuilds={guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/webui/src/app/owner/jobs/page.tsx
Normal file
30
apps/webui/src/app/owner/jobs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { JobsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getAllQueueStats } from '@/lib/owner-jobs';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerJobsPage() {
|
||||
const locale = await getLocale();
|
||||
const [queues, migrations] = await Promise.all([
|
||||
getAllQueueStats(),
|
||||
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
|
||||
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
|
||||
`
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.jobs.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.jobs.subtitle')}</p>
|
||||
</div>
|
||||
<JobsManager
|
||||
initialQueues={queues}
|
||||
initialMigrations={migrations.map((row) => ({
|
||||
name: row.migration_name,
|
||||
finishedAt: row.finished_at?.toISOString() ?? null
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/webui/src/app/owner/layout.tsx
Normal file
7
apps/webui/src/app/owner/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { OwnerShell } from '@/components/owner/owner-shell';
|
||||
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||
|
||||
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireOwnerOrRedirect('VIEWER');
|
||||
return <OwnerShell user={session.user}>{children}</OwnerShell>;
|
||||
}
|
||||
97
apps/webui/src/app/owner/page.tsx
Normal file
97
apps/webui/src/app/owner/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerOverviewPage() {
|
||||
const locale = await getLocale();
|
||||
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] = await Promise.all([
|
||||
prisma.guild.count(),
|
||||
prisma.globalUserBlacklist.count(),
|
||||
prisma.guildBlacklist.count(),
|
||||
getPresenceConfig(),
|
||||
listOwnerAudit(10),
|
||||
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
|
||||
]);
|
||||
|
||||
const cards = [
|
||||
{ label: t(locale, 'owner.overview.guilds'), value: String(guildCount) },
|
||||
{ label: t(locale, 'owner.overview.blacklistedUsers'), value: String(blacklistUsers) },
|
||||
{ label: t(locale, 'owner.overview.blacklistedGuilds'), value: String(blacklistGuilds) },
|
||||
{
|
||||
label: t(locale, 'owner.overview.maintenance'),
|
||||
value: presence.maintenanceMode ? t(locale, 'common.yes') : t(locale, 'common.no')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'owner.overview.uptime'),
|
||||
value: `${Math.floor(process.uptime() / 60)} min`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.overview.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.overview.subtitle')}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{card.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{card.value}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t(locale, 'owner.overview.recentAudit')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyAudit')}</p>
|
||||
) : (
|
||||
audit.map((entry) => (
|
||||
<div key={entry.id} className="flex justify-between gap-2 border-b border-border/60 py-2 last:border-0">
|
||||
<span>
|
||||
{entry.action}
|
||||
{entry.targetId ? ` · ${entry.targetId}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Link href="/owner/audit" className="text-primary hover:underline">
|
||||
{t(locale, 'owner.overview.viewAudit')}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t(locale, 'owner.overview.changelog')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{changelog.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyChangelog')}</p>
|
||||
) : (
|
||||
changelog.map((entry) => (
|
||||
<div key={entry.id} className="border-b border-border/60 py-2 last:border-0">
|
||||
<p className="font-medium">
|
||||
{entry.version} — {entry.title}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Link href="/owner/changelog" className="text-primary hover:underline">
|
||||
{t(locale, 'owner.overview.manageChangelog')}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/webui/src/app/owner/presence/page.tsx
Normal file
16
apps/webui/src/app/owner/presence/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PresenceForm } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
|
||||
export default async function OwnerPresencePage() {
|
||||
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
|
||||
</div>
|
||||
<PresenceForm initialValue={config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/app/owner/team/page.tsx
Normal file
21
apps/webui/src/app/owner/team/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TeamManager } from '@/components/owner/owner-forms';
|
||||
import { env } from '@/lib/env';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerTeamPage() {
|
||||
const [locale, members] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.team.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.team.subtitle')}</p>
|
||||
</div>
|
||||
<TeamManager bootstrapOwnerIds={env.OWNER_USER_IDS ?? []} initialMembers={members} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/webui/src/app/owner/users/page.tsx
Normal file
20
apps/webui/src/app/owner/users/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { UsersManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerUsersPage() {
|
||||
const [locale, users] = await Promise.all([
|
||||
getLocale(),
|
||||
prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.users.subtitle')}</p>
|
||||
</div>
|
||||
<UsersManager initialUsers={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,17 +9,25 @@ interface DashboardShellProps {
|
||||
currentGuild: DashboardGuild;
|
||||
otherGuilds: DashboardGuild[];
|
||||
user: SessionUser;
|
||||
showOwnerLink?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) {
|
||||
export function DashboardShell({
|
||||
guildId,
|
||||
currentGuild,
|
||||
otherGuilds,
|
||||
user,
|
||||
showOwnerLink = false,
|
||||
children
|
||||
}: DashboardShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar guildId={guildId} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center justify-between border-b border-border px-6">
|
||||
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
||||
<UserMenu user={user} />
|
||||
<UserMenu user={user} showOwnerLink={showOwnerLink} />
|
||||
</header>
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { SessionUser } from '@nexumi/shared';
|
||||
import { LogOut, Moon, Sun } from 'lucide-react';
|
||||
import { LogOut, Moon, Shield, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
@@ -26,7 +26,7 @@ function initials(user: SessionUser): string {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function UserMenu({ user }: { user: SessionUser }) {
|
||||
export function UserMenu({ user, showOwnerLink = false }: { user: SessionUser; showOwnerLink?: boolean }) {
|
||||
const t = useTranslations();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
@@ -58,6 +58,19 @@ export function UserMenu({ user }: { user: SessionUser }) {
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{showOwnerLink ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
router.push('/owner');
|
||||
}}
|
||||
>
|
||||
<Shield className="size-4" />
|
||||
{t('userMenu.ownerPanel')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
||||
|
||||
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",
|
||||
"themeLight": "Hell",
|
||||
"logout": "Abmelden",
|
||||
"loggingOut": "Meldet ab…"
|
||||
"loggingOut": "Meldet ab…",
|
||||
"ownerPanel": "Owner-Panel"
|
||||
},
|
||||
"owner": {
|
||||
"nav": {
|
||||
"title": "Owner",
|
||||
"backToDashboard": "Zum Dashboard",
|
||||
"overview": "Übersicht",
|
||||
"guilds": "Server",
|
||||
"users": "User-Blacklist",
|
||||
"flags": "Feature-Flags",
|
||||
"presence": "Präsenz",
|
||||
"team": "Team",
|
||||
"jobs": "Jobs",
|
||||
"changelog": "Changelog",
|
||||
"audit": "Audit"
|
||||
},
|
||||
"common": {
|
||||
"empty": "Keine Einträge.",
|
||||
"noteOptional": "Notiz (optional)"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Owner-Übersicht",
|
||||
"subtitle": "Globale Bot-Kennzahlen und letzte Owner-Aktionen.",
|
||||
"guilds": "Server",
|
||||
"blacklistedUsers": "Gesperrte User",
|
||||
"blacklistedGuilds": "Gesperrte Server",
|
||||
"maintenance": "Wartungsmodus",
|
||||
"uptime": "WebUI-Uptime",
|
||||
"recentAudit": "Letzte Owner-Aktivität",
|
||||
"emptyAudit": "Noch keine Owner-Aktionen.",
|
||||
"viewAudit": "Audit öffnen",
|
||||
"changelog": "Changelog",
|
||||
"emptyChangelog": "Noch keine Changelog-Einträge.",
|
||||
"manageChangelog": "Changelog verwalten"
|
||||
},
|
||||
"guilds": {
|
||||
"title": "Server-Verwaltung",
|
||||
"subtitle": "Server suchen, blacklisten oder den Bot entfernen.",
|
||||
"search": "Server-ID suchen…",
|
||||
"blacklisted": "Blacklist",
|
||||
"blacklist": "Blacklisten",
|
||||
"unblacklist": "Blacklist entfernen",
|
||||
"leave": "Server verlassen",
|
||||
"leaveConfirm": "Bot wirklich von diesem Server entfernen?"
|
||||
},
|
||||
"users": {
|
||||
"title": "User-Blacklist",
|
||||
"subtitle": "Global gesperrte Discord-User (Bot ignoriert sie überall).",
|
||||
"userIdPlaceholder": "Discord User-ID"
|
||||
},
|
||||
"flags": {
|
||||
"title": "Feature-Flags",
|
||||
"subtitle": "Module global steuern und Rollouts konfigurieren.",
|
||||
"keyPlaceholder": "Flag-Key (z. B. new.module)",
|
||||
"rollout": "Rollout %"
|
||||
},
|
||||
"presence": {
|
||||
"title": "Bot-Präsenz",
|
||||
"subtitle": "Status, Aktivität und Wartungsmodus.",
|
||||
"status": "Status",
|
||||
"activityType": "Aktivitätstyp",
|
||||
"activityText": "Aktivitätstext",
|
||||
"rotating": "Rotierende Nachrichten (eine pro Zeile)",
|
||||
"maintenanceMode": "Wartungsmodus aktiv",
|
||||
"maintenanceMessage": "Wartungsnachricht"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"subtitle": "Weitere Owner/Admins mit abgestuften Rechten.",
|
||||
"bootstrap": "Bootstrap-Owner (OWNER_USER_IDS)",
|
||||
"noBootstrap": "Keine Bootstrap-Owner in OWNER_USER_IDS gesetzt."
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Jobs & Migrationen",
|
||||
"subtitle": "BullMQ-Queues und Prisma-Migrationsstatus.",
|
||||
"queues": "Queues",
|
||||
"migrations": "Migrationen",
|
||||
"retryFailed": "Fehlgeschlagene neu starten"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"subtitle": "Versionshinweise für das Dashboard.",
|
||||
"version": "Version",
|
||||
"titleField": "Titel",
|
||||
"body": "Inhalt"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Owner-Audit",
|
||||
"subtitle": "Alle Owner-Aktionen im globalen Audit-Log."
|
||||
}
|
||||
},
|
||||
"serverSwitcher": {
|
||||
"switchServer": "Server wechseln",
|
||||
|
||||
@@ -518,7 +518,97 @@
|
||||
"themeDark": "Dark",
|
||||
"themeLight": "Light",
|
||||
"logout": "Log out",
|
||||
"loggingOut": "Logging out…"
|
||||
"loggingOut": "Logging out…",
|
||||
"ownerPanel": "Owner panel"
|
||||
},
|
||||
"owner": {
|
||||
"nav": {
|
||||
"title": "Owner",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"overview": "Overview",
|
||||
"guilds": "Guilds",
|
||||
"users": "User blacklist",
|
||||
"flags": "Feature flags",
|
||||
"presence": "Presence",
|
||||
"team": "Team",
|
||||
"jobs": "Jobs",
|
||||
"changelog": "Changelog",
|
||||
"audit": "Audit"
|
||||
},
|
||||
"common": {
|
||||
"empty": "No entries.",
|
||||
"noteOptional": "Note (optional)"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Owner overview",
|
||||
"subtitle": "Global bot metrics and recent owner actions.",
|
||||
"guilds": "Guilds",
|
||||
"blacklistedUsers": "Blacklisted users",
|
||||
"blacklistedGuilds": "Blacklisted guilds",
|
||||
"maintenance": "Maintenance mode",
|
||||
"uptime": "WebUI uptime",
|
||||
"recentAudit": "Recent owner activity",
|
||||
"emptyAudit": "No owner actions yet.",
|
||||
"viewAudit": "Open audit",
|
||||
"changelog": "Changelog",
|
||||
"emptyChangelog": "No changelog entries yet.",
|
||||
"manageChangelog": "Manage changelog"
|
||||
},
|
||||
"guilds": {
|
||||
"title": "Guild management",
|
||||
"subtitle": "Search guilds, blacklist them, or leave with the bot.",
|
||||
"search": "Search guild ID…",
|
||||
"blacklisted": "Blacklisted",
|
||||
"blacklist": "Blacklist",
|
||||
"unblacklist": "Remove blacklist",
|
||||
"leave": "Leave guild",
|
||||
"leaveConfirm": "Really remove the bot from this guild?"
|
||||
},
|
||||
"users": {
|
||||
"title": "User blacklist",
|
||||
"subtitle": "Globally blocked Discord users (bot ignores them everywhere).",
|
||||
"userIdPlaceholder": "Discord user ID"
|
||||
},
|
||||
"flags": {
|
||||
"title": "Feature flags",
|
||||
"subtitle": "Control modules globally and configure rollouts.",
|
||||
"keyPlaceholder": "Flag key (e.g. new.module)",
|
||||
"rollout": "Rollout %"
|
||||
},
|
||||
"presence": {
|
||||
"title": "Bot presence",
|
||||
"subtitle": "Status, activity, and maintenance mode.",
|
||||
"status": "Status",
|
||||
"activityType": "Activity type",
|
||||
"activityText": "Activity text",
|
||||
"rotating": "Rotating messages (one per line)",
|
||||
"maintenanceMode": "Maintenance mode enabled",
|
||||
"maintenanceMessage": "Maintenance message"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"subtitle": "Additional owners/admins with graded permissions.",
|
||||
"bootstrap": "Bootstrap owners (OWNER_USER_IDS)",
|
||||
"noBootstrap": "No bootstrap owners set in OWNER_USER_IDS."
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Jobs & migrations",
|
||||
"subtitle": "BullMQ queues and Prisma migration status.",
|
||||
"queues": "Queues",
|
||||
"migrations": "Migrations",
|
||||
"retryFailed": "Retry failed"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"subtitle": "Release notes for the dashboard.",
|
||||
"version": "Version",
|
||||
"titleField": "Title",
|
||||
"body": "Body"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Owner audit",
|
||||
"subtitle": "All owner actions in the global audit log."
|
||||
}
|
||||
},
|
||||
"serverSwitcher": {
|
||||
"switchServer": "Switch server",
|
||||
|
||||
@@ -24,5 +24,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*']
|
||||
matcher: ['/dashboard/:path*', '/owner/:path*']
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user