Nach einem Passwortwechsel fliegen alle anderen Geräte raus; ein gesperrtes Konto wird sofort abgemeldet. Die letzte Administration lässt sich nicht entmachten, und sich selbst kann niemand sperren. Das öffentliche Profil zeigt Zahlen und zuletzt Gesehenes — keine Notizen, keine Watchlist, keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Es braucht die Freigabe des Nutzers und die des Betreibers.
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { requireAdmin } from "@/lib/auth";
|
|
import { getSettings } from "@/lib/settings";
|
|
import { prisma } from "@/lib/db";
|
|
import { env } from "@/lib/env";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { AdminSettings, UserRow } from "@/components/settings/Forms";
|
|
import { formatRelative } from "@/lib/labels";
|
|
|
|
export const metadata: Metadata = { title: "Verwaltung" };
|
|
|
|
export default async function AdminPage() {
|
|
const admin = await requireAdmin();
|
|
const settings = await getSettings();
|
|
|
|
const [users, lastRun, catalog] = await Promise.all([
|
|
prisma.user.findMany({
|
|
orderBy: { createdAt: "asc" },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
isActive: true,
|
|
_count: { select: { entries: true } },
|
|
},
|
|
}),
|
|
prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }),
|
|
Promise.all([
|
|
prisma.title.count(),
|
|
prisma.episode.count(),
|
|
prisma.watch.count(),
|
|
]),
|
|
]);
|
|
|
|
const [titleCount, episodeCount, watchCount] = catalog;
|
|
|
|
return (
|
|
<AppShell user={admin} instanceName={settings.instanceName}>
|
|
<header className="mb-6">
|
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
|
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
|
Einstellungen der Instanz, Konten und Hintergrundlauf.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<section className="fl-card p-5">
|
|
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
|
|
<AdminSettings
|
|
registrationEnabled={settings.registrationEnabled}
|
|
publicProfilesEnabled={settings.publicProfilesEnabled}
|
|
instanceName={settings.instanceName}
|
|
/>
|
|
</section>
|
|
|
|
<div className="space-y-6">
|
|
<section className="fl-card p-5">
|
|
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
|
|
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
<Fact label="Konten" value={String(users.length)} />
|
|
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
|
|
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
|
|
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
|
|
<Fact
|
|
label="TMDB"
|
|
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
|
|
/>
|
|
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
|
<Fact
|
|
label="Zeitplan"
|
|
value={
|
|
env.scheduler.enabled
|
|
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
|
|
: "abgeschaltet"
|
|
}
|
|
/>
|
|
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="fl-card p-5">
|
|
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
|
|
{lastRun ? (
|
|
<p className="text-sm text-[var(--fl-muted)]">
|
|
{formatRelative(lastRun.startedAt)} ·{" "}
|
|
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
|
|
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
|
|
</span>
|
|
{lastRun.message ? ` · ${lastRun.message}` : ""}
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="fl-card mt-6 p-5">
|
|
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
|
|
<ul className="divide-y divide-[var(--fl-border)]">
|
|
{users.map((user) => (
|
|
<UserRow
|
|
key={user.id}
|
|
user={{
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
role: user.role,
|
|
isActive: user.isActive,
|
|
entries: user._count.entries,
|
|
}}
|
|
isSelf={user.id === admin.id}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function Fact({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
|
<dd className="mt-0.5 font-medium">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|