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