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,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { ApiKeys } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "API-Schlüssel" };
|
||||
|
||||
export default async function ApiPage() {
|
||||
const user = await requireUser("/einstellungen/api");
|
||||
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
const example = [
|
||||
'curl -H "Authorization: Bearer <dein-schlüssel>" \\',
|
||||
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto —
|
||||
gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst.
|
||||
</p>
|
||||
<ApiKeys keys={keys} />
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
|
||||
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
|
||||
{example}
|
||||
</pre>
|
||||
<p className="mt-3 text-sm text-[var(--fl-muted)]">
|
||||
Die vollständige Beschreibung steht in{" "}
|
||||
<a
|
||||
href="/api/v1/openapi.json"
|
||||
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
|
||||
>
|
||||
openapi.json
|
||||
</a>{" "}
|
||||
und in <code>docs/API.md</code> im Repository.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { ChannelList } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "Benachrichtigungen" };
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
const user = await requireUser("/einstellungen/benachrichtigungen");
|
||||
|
||||
const [channels, recent] = await Promise.all([
|
||||
prisma.notificationChannel.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.notificationLog.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Kanäle</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
|
||||
</p>
|
||||
<ChannelList
|
||||
channels={channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
kind: channel.kind,
|
||||
name: channel.name,
|
||||
config: (channel.config ?? {}) as Record<string, string>,
|
||||
enabled: channel.enabled,
|
||||
}))}
|
||||
smtpReady={env.smtp.isConfigured}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--fl-border)] text-sm">
|
||||
{recent.map((entry) => (
|
||||
<li key={entry.id} className="py-2.5">
|
||||
<p className="font-medium">{entry.subject}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{entry.sentAt.toLocaleString("de-DE")}
|
||||
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ImportForm } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "Import und Export" };
|
||||
|
||||
export default async function DataPage() {
|
||||
const user = await requireUser("/einstellungen/daten");
|
||||
|
||||
const [entries, watches] = await Promise.all([
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
prisma.watch.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Import</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Bringt Titel aus einem anderen Werkzeug mit.
|
||||
</p>
|
||||
<ImportForm />
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Export</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
|
||||
Als JSON
|
||||
</a>
|
||||
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
|
||||
Als CSV
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
|
||||
für Tabellenkalkulationen.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SettingsNav } from "@/components/settings/SettingsNav";
|
||||
|
||||
/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */
|
||||
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser("/einstellungen");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<SettingsNav isAdmin={user.role === "ADMIN"} />
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await requireUser("/einstellungen");
|
||||
const settings = await getSettings();
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.id },
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
region: true,
|
||||
publicProfile: true,
|
||||
profileSlug: true,
|
||||
notifyNewEpisode: true,
|
||||
notifyRelease: true,
|
||||
notifyAvailability: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { passwordHash, ...profile } = user;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
|
||||
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
|
||||
</section>
|
||||
|
||||
{passwordHash && (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
|
||||
<PasswordForm />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user