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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user