feat: Einstellungen, Verwaltung und öffentliches Profil
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.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user