Implement Owner Panel features and maintenance mode handling

- Introduced the Owner Panel with new routes and UI components for managing guilds, user blacklists, feature flags, and bot presence.
- Added maintenance mode functionality to prevent non-owner users from executing commands during maintenance periods.
- Enhanced localization with new keys for Owner Panel features in both English and German.
- Updated job processing to include a presence refresh job, ensuring the bot's status is regularly updated.
- Improved environment configuration to support owner user IDs for access control.
This commit is contained in:
smueller
2026-07-22 16:10:30 +02:00
parent bcbfa07697
commit 8a8aefb4fb
42 changed files with 2385 additions and 50 deletions

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { toApiErrorResponse } from '@/lib/auth';
import { listOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
export async function GET(request: Request) {
try {
await requireOwner('VIEWER');
const { searchParams } = new URL(request.url);
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100);
const offset = Math.max(Number(searchParams.get('offset') ?? 0), 0);
const entries = await listOwnerAudit(limit, offset);
return NextResponse.json({ entries });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { ChangelogEntrySchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const entries = await prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } });
return NextResponse.json({
entries: entries.map((entry) => ({
...entry,
publishedAt: entry.publishedAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
updatedAt: entry.updatedAt.toISOString()
}))
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = ChangelogEntrySchema.parse(await request.json());
const entry = await prisma.changelogEntry.create({
data: {
version: body.version,
title: body.title,
body: body.body,
publishedAt: body.publishedAt ? new Date(body.publishedAt) : undefined
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'changelog.create',
targetType: 'ChangelogEntry',
targetId: entry.id,
after: entry
});
return NextResponse.json(entry);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('ADMIN');
const id = new URL(request.url).searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'id required' }, { status: 400 });
}
const before = await prisma.changelogEntry.findUnique({ where: { id } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.changelogEntry.delete({ where: { id } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'changelog.delete',
targetType: 'ChangelogEntry',
targetId: id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server';
import { FeatureFlagUpsertSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const flags = await prisma.featureFlag.findMany({ orderBy: { key: 'asc' } });
return NextResponse.json({ flags });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = FeatureFlagUpsertSchema.parse(await request.json());
const before = await prisma.featureFlag.findUnique({ where: { key: body.key } });
const flag = await prisma.featureFlag.upsert({
where: { key: body.key },
create: {
key: body.key,
enabled: body.enabled,
rolloutPercent: body.rolloutPercent,
whitelistGuildIds: body.whitelistGuildIds
},
update: {
enabled: body.enabled,
rolloutPercent: body.rolloutPercent,
whitelistGuildIds: body.whitelistGuildIds
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: before ? 'flags.update' : 'flags.create',
targetType: 'FeatureFlag',
targetId: flag.id,
before,
after: flag
});
return NextResponse.json(flag);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('ADMIN');
const key = new URL(request.url).searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'key required' }, { status: 400 });
}
const before = await prisma.featureFlag.findUnique({ where: { key } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.featureFlag.delete({ where: { key } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'flags.delete',
targetType: 'FeatureFlag',
targetId: before.id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from 'next/server';
import { GuildBlacklistSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { env } from '@/lib/env';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
await requireOwner('VIEWER');
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
const guilds = await prisma.guild.findMany({
where: q ? { id: { contains: q } } : undefined,
include: { settings: true },
orderBy: { createdAt: 'desc' },
take: 100
});
const blacklist = await prisma.guildBlacklist.findMany();
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
return NextResponse.json({
guilds: guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
})),
blacklist
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
}
if (body.action === 'leave') {
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
method: 'DELETE',
headers: { Authorization: `Bot ${env.BOT_TOKEN}` }
});
if (!response.ok && response.status !== 404) {
return NextResponse.json({ error: `Discord leave failed (${response.status})` }, { status: 502 });
}
await prisma.guild.delete({ where: { id: body.guildId } }).catch(() => undefined);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.leave',
targetType: 'Guild',
targetId: body.guildId
});
return NextResponse.json({ ok: true });
}
if (body.action === 'blacklist') {
const parsed = GuildBlacklistSchema.parse({
guildId: body.guildId,
reason: body.reason ?? null
});
const row = await prisma.guildBlacklist.upsert({
where: { guildId: parsed.guildId },
create: {
guildId: parsed.guildId,
reason: parsed.reason ?? null,
createdById: session.user.id
},
update: { reason: parsed.reason ?? null }
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.blacklist',
targetType: 'GuildBlacklist',
targetId: row.id,
after: row
});
return NextResponse.json(row);
}
if (body.action === 'unblacklist') {
const before = await prisma.guildBlacklist.findUnique({ where: { guildId: body.guildId } });
if (before) {
await prisma.guildBlacklist.delete({ where: { guildId: body.guildId } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.unblacklist',
targetType: 'GuildBlacklist',
targetId: before.id,
before
});
}
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import { toApiErrorResponse } from '@/lib/auth';
import { getAllQueueStats, retryFailedJobs } from '@/lib/owner-jobs';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('SUPPORT');
const [queues, migrations] = await Promise.all([
getAllQueueStats(),
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
`
]);
return NextResponse.json({
queues,
migrations: migrations.map((row) => ({
name: row.migration_name,
finishedAt: row.finished_at?.toISOString() ?? null
}))
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = (await request.json()) as { queueName?: string };
if (!body.queueName) {
return NextResponse.json({ error: 'queueName required' }, { status: 400 });
}
const retried = await retryFailedJobs(body.queueName);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'jobs.retryFailed',
targetType: 'Queue',
targetId: body.queueName,
after: { retried }
});
return NextResponse.json({ retried });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { toApiErrorResponse } from '@/lib/auth';
import { listOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
import { getPresenceConfig } from '@/lib/owner-presence';
export async function GET() {
try {
await requireOwner('VIEWER');
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] =
await Promise.all([
prisma.guild.count(),
prisma.globalUserBlacklist.count(),
prisma.guildBlacklist.count(),
getPresenceConfig(),
listOwnerAudit(10),
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
]);
return NextResponse.json({
guildCount,
blacklistUsers,
blacklistGuilds,
uptimeSeconds: Math.floor(process.uptime()),
maintenanceMode: presence.maintenanceMode,
recentAudit: audit,
changelog: changelog.map((entry) => ({
id: entry.id,
version: entry.version,
title: entry.title,
publishedAt: entry.publishedAt.toISOString()
}))
});
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { BotPresenceConfigPatchSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { getPresenceConfig, updatePresenceConfig } from '@/lib/owner-presence';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
return NextResponse.json(await getPresenceConfig());
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = BotPresenceConfigPatchSchema.parse(await request.json());
const before = await getPresenceConfig();
const after = await updatePresenceConfig(body);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'presence.update',
targetType: 'BotPresenceConfig',
targetId: 'singleton',
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { OwnerTeamUpsertSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { env } from '@/lib/env';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const members = await prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } });
return NextResponse.json({
bootstrapOwnerIds: env.OWNER_USER_IDS ?? [],
members
});
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireOwner('OWNER');
const body = OwnerTeamUpsertSchema.parse(await request.json());
const before = await prisma.ownerTeamMember.findUnique({ where: { userId: body.userId } });
const member = await prisma.ownerTeamMember.upsert({
where: { userId: body.userId },
create: {
userId: body.userId,
role: body.role,
note: body.note ?? null
},
update: {
role: body.role,
note: body.note ?? null
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: before ? 'team.update' : 'team.create',
targetType: 'OwnerTeamMember',
targetId: member.id,
before,
after: member
});
return NextResponse.json(member);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('OWNER');
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const before = await prisma.ownerTeamMember.findUnique({ where: { userId } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.ownerTeamMember.delete({ where: { userId } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'team.delete',
targetType: 'OwnerTeamMember',
targetId: before.id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { GlobalUserBlacklistSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
const users = await prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } });
return NextResponse.json({ users });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireOwner('SUPPORT');
const body = GlobalUserBlacklistSchema.parse(await request.json());
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId: body.userId } });
const row = await prisma.globalUserBlacklist.upsert({
where: { userId: body.userId },
create: {
userId: body.userId,
reason: body.reason ?? null,
note: body.note ?? null,
createdById: session.user.id
},
update: {
reason: body.reason ?? null,
note: body.note ?? null
}
});
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: before ? 'users.blacklist.update' : 'users.blacklist.create',
targetType: 'GlobalUserBlacklist',
targetId: row.id,
before,
after: row
});
return NextResponse.json(row);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const session = await requireOwner('SUPPORT');
const userId = new URL(request.url).searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId } });
if (!before) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.globalUserBlacklist.delete({ where: { userId } });
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'users.blacklist.delete',
targetType: 'GlobalUserBlacklist',
targetId: before.id,
before
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { DashboardShell } from '@/components/layout/dashboard-shell';
import { requireGuildAccessOrRedirect } from '@/lib/auth';
import { getManageableGuilds } from '@/lib/guilds';
import { isOwnerUser } from '@/lib/owner-auth';
interface GuildLayoutProps {
children: ReactNode;
@@ -20,9 +21,16 @@ export default async function GuildLayout({ children, params }: GuildLayoutProps
}
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
const showOwnerLink = await isOwnerUser(session.user.id);
return (
<DashboardShell guildId={guildId} currentGuild={currentGuild} otherGuilds={otherGuilds} user={session.user}>
<DashboardShell
guildId={guildId}
currentGuild={currentGuild}
otherGuilds={otherGuilds}
user={session.user}
showOwnerLink={showOwnerLink}
>
{children}
</DashboardShell>
);

View File

@@ -0,0 +1,37 @@
import { Card, CardContent } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
import { listOwnerAudit } from '@/lib/owner-audit';
export default async function OwnerAuditPage() {
const [locale, entries] = await Promise.all([getLocale(), listOwnerAudit(100)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.audit.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.audit.subtitle')}</p>
</div>
<Card>
<CardContent className="divide-y divide-border p-0">
{entries.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t(locale, 'owner.common.empty')}</p>
) : (
entries.map((entry) => (
<div key={entry.id} className="flex flex-col gap-1 px-4 py-3 text-sm sm:flex-row sm:justify-between">
<div>
<p className="font-medium">{entry.action}</p>
<p className="text-muted-foreground">
{entry.actorUserId}
{entry.targetType ? ` · ${entry.targetType}` : ''}
{entry.targetId ? ` · ${entry.targetId}` : ''}
</p>
</div>
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { ChangelogManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { prisma } from '@/lib/prisma';
export default async function OwnerChangelogPage() {
const [locale, entries] = await Promise.all([
getLocale(),
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } })
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.changelog.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.changelog.subtitle')}</p>
</div>
<ChangelogManager
initialEntries={entries.map((entry) => ({
id: entry.id,
version: entry.version,
title: entry.title,
body: entry.body,
publishedAt: entry.publishedAt.toISOString()
}))}
/>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { FlagsManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { prisma } from '@/lib/prisma';
export default async function OwnerFlagsPage() {
const [locale, flags] = await Promise.all([
getLocale(),
prisma.featureFlag.findMany({ orderBy: { key: 'asc' } })
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.flags.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.flags.subtitle')}</p>
</div>
<FlagsManager initialFlags={flags} />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { GuildsManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { prisma } from '@/lib/prisma';
export default async function OwnerGuildsPage() {
const locale = await getLocale();
const [guilds, blacklist] = await Promise.all([
prisma.guild.findMany({
include: { settings: true },
orderBy: { createdAt: 'desc' },
take: 200
}),
prisma.guildBlacklist.findMany()
]);
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
</div>
<GuildsManager
initialGuilds={guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
}))}
/>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { JobsManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { getAllQueueStats } from '@/lib/owner-jobs';
import { prisma } from '@/lib/prisma';
export default async function OwnerJobsPage() {
const locale = await getLocale();
const [queues, migrations] = await Promise.all([
getAllQueueStats(),
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
`
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.jobs.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.jobs.subtitle')}</p>
</div>
<JobsManager
initialQueues={queues}
initialMigrations={migrations.map((row) => ({
name: row.migration_name,
finishedAt: row.finished_at?.toISOString() ?? null
}))}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { OwnerShell } from '@/components/owner/owner-shell';
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
const session = await requireOwnerOrRedirect('VIEWER');
return <OwnerShell user={session.user}>{children}</OwnerShell>;
}

View File

@@ -0,0 +1,97 @@
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
import { listOwnerAudit } from '@/lib/owner-audit';
import { getPresenceConfig } from '@/lib/owner-presence';
import { prisma } from '@/lib/prisma';
export default async function OwnerOverviewPage() {
const locale = await getLocale();
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] = await Promise.all([
prisma.guild.count(),
prisma.globalUserBlacklist.count(),
prisma.guildBlacklist.count(),
getPresenceConfig(),
listOwnerAudit(10),
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
]);
const cards = [
{ label: t(locale, 'owner.overview.guilds'), value: String(guildCount) },
{ label: t(locale, 'owner.overview.blacklistedUsers'), value: String(blacklistUsers) },
{ label: t(locale, 'owner.overview.blacklistedGuilds'), value: String(blacklistGuilds) },
{
label: t(locale, 'owner.overview.maintenance'),
value: presence.maintenanceMode ? t(locale, 'common.yes') : t(locale, 'common.no')
},
{
label: t(locale, 'owner.overview.uptime'),
value: `${Math.floor(process.uptime() / 60)} min`
}
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.overview.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.overview.subtitle')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<Card key={card.label}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{card.label}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">{card.value}</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t(locale, 'owner.overview.recentAudit')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{audit.length === 0 ? (
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyAudit')}</p>
) : (
audit.map((entry) => (
<div key={entry.id} className="flex justify-between gap-2 border-b border-border/60 py-2 last:border-0">
<span>
{entry.action}
{entry.targetId ? ` · ${entry.targetId}` : ''}
</span>
<span className="text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</span>
</div>
))
)}
<Link href="/owner/audit" className="text-primary hover:underline">
{t(locale, 'owner.overview.viewAudit')}
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t(locale, 'owner.overview.changelog')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{changelog.length === 0 ? (
<p className="text-muted-foreground">{t(locale, 'owner.overview.emptyChangelog')}</p>
) : (
changelog.map((entry) => (
<div key={entry.id} className="border-b border-border/60 py-2 last:border-0">
<p className="font-medium">
{entry.version} {entry.title}
</p>
</div>
))
)}
<Link href="/owner/changelog" className="text-primary hover:underline">
{t(locale, 'owner.overview.manageChangelog')}
</Link>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { PresenceForm } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { getPresenceConfig } from '@/lib/owner-presence';
export default async function OwnerPresencePage() {
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
</div>
<PresenceForm initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { TeamManager } from '@/components/owner/owner-forms';
import { env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
import { prisma } from '@/lib/prisma';
export default async function OwnerTeamPage() {
const [locale, members] = await Promise.all([
getLocale(),
prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } })
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.team.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.team.subtitle')}</p>
</div>
<TeamManager bootstrapOwnerIds={env.OWNER_USER_IDS ?? []} initialMembers={members} />
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { UsersManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { prisma } from '@/lib/prisma';
export default async function OwnerUsersPage() {
const [locale, users] = await Promise.all([
getLocale(),
prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } })
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.users.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.users.subtitle')}</p>
</div>
<UsersManager initialUsers={users} />
</div>
);
}