feat: Registrierung, Anmeldung und Sitzungen
Passwörter mit bcrypt, Sitzungen als opake Token in der Datenbank — im Cookie steht das Klartext-Token, in der Tabelle nur dessen SHA-256. Damit lassen sich einzelne Geräte gezielt abmelden, was reine JWT-Sitzungen nicht hergeben. Der erste registrierte Zugang wird automatisch Administrator, sonst käme man nach dem Aufsetzen nicht in die Verwaltung. Die Registrierung ist instanzweit abschaltbar; solange es keinen Nutzer gibt, bleibt sie offen. Enthält außerdem API-Schlüssel je Nutzer als Grundlage für die spätere REST-API und die Instanz-Einstellungen in der Tabelle AppSetting.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isRegistrationOpen } from "@/lib/settings";
|
||||
import {
|
||||
checkPasswordStrength,
|
||||
createSession,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth";
|
||||
|
||||
export type AuthFormState = { error?: string };
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
email: z.string().trim().toLowerCase().email(),
|
||||
password: z.string().min(1),
|
||||
passwordRepeat: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Anmeldung.
|
||||
*
|
||||
* Fehlermeldungen sind bewusst unspezifisch („E-Mail oder Passwort stimmt
|
||||
* nicht"), damit sich über das Formular nicht herausfinden lässt, welche
|
||||
* Adressen ein Konto haben.
|
||||
*/
|
||||
export async function loginAction(
|
||||
_prev: AuthFormState,
|
||||
formData: FormData,
|
||||
): Promise<AuthFormState> {
|
||||
const parsed = loginSchema.safeParse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: "E-Mail-Adresse oder Passwort stimmt nicht." };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
select: { id: true, passwordHash: true, isActive: true },
|
||||
});
|
||||
|
||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät.
|
||||
const hash = user?.passwordHash ?? "$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidin";
|
||||
const ok = await verifyPassword(parsed.data.password, hash);
|
||||
|
||||
if (!user || !user.passwordHash || !ok) {
|
||||
return { error: "E-Mail-Adresse oder Passwort stimmt nicht." };
|
||||
}
|
||||
if (!user.isActive) {
|
||||
return { error: "Dieses Konto ist deaktiviert." };
|
||||
}
|
||||
|
||||
await createSession(user.id);
|
||||
|
||||
const target = String(formData.get("weiter") ?? "/");
|
||||
redirect(target.startsWith("/") && !target.startsWith("//") ? target : "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrierung. Der allererste Zugang wird automatisch Administrator —
|
||||
* sonst käme man nach dem Aufsetzen nicht in den Verwaltungsbereich.
|
||||
*/
|
||||
export async function registerAction(
|
||||
_prev: AuthFormState,
|
||||
formData: FormData,
|
||||
): Promise<AuthFormState> {
|
||||
if (!(await isRegistrationOpen())) {
|
||||
return { error: "Die Registrierung ist auf dieser Instanz deaktiviert." };
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
passwordRepeat: formData.get("passwordRepeat"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: "Bitte fülle alle Felder korrekt aus." };
|
||||
}
|
||||
|
||||
const { name, email, password, passwordRepeat } = parsed.data;
|
||||
|
||||
if (password !== passwordRepeat) {
|
||||
return { error: "Die beiden Passwörter stimmen nicht überein." };
|
||||
}
|
||||
const weak = checkPasswordStrength(password);
|
||||
if (weak) return { error: weak };
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||
if (existing) {
|
||||
return { error: "Für diese E-Mail-Adresse gibt es bereits ein Konto." };
|
||||
}
|
||||
|
||||
const isFirstUser = (await prisma.user.count()) === 0;
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
passwordHash: await hashPassword(password),
|
||||
role: isFirstUser ? "ADMIN" : "USER",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
redirect("/");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { getSettings, isRegistrationOpen } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import { AuthSwitch, LoginForm } from "@/components/AuthForm";
|
||||
import { loginAction } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ weiter?: string; fehler?: string }>;
|
||||
}) {
|
||||
if (await getSessionUser()) redirect("/");
|
||||
|
||||
const params = await searchParams;
|
||||
const [settings, registrationOpen] = await Promise.all([getSettings(), isRegistrationOpen()]);
|
||||
|
||||
return (
|
||||
<div className="fl-card p-6 sm:p-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Willkommen zurück</h1>
|
||||
<p className="mt-1 mb-6 text-sm text-[var(--fl-muted)]">
|
||||
Melde dich an, um weiterzuschauen.
|
||||
</p>
|
||||
|
||||
{params.fehler && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-4 rounded-xl border border-[color-mix(in_oklab,var(--fl-danger)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-danger)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-danger)]"
|
||||
>
|
||||
{params.fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LoginForm action={loginAction} weiter={params.weiter} />
|
||||
|
||||
{env.oidc.isConfigured && (
|
||||
<>
|
||||
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
</div>
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
|
||||
{env.oidc.buttonLabel}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{registrationOpen && (
|
||||
<div className="mt-6">
|
||||
<AuthSwitch href="/registrieren">Noch kein Konto? Jetzt registrieren</AuthSwitch>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-xs text-[var(--fl-faint)]">{settings.instanceName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
/**
|
||||
* Rahmen für Anmeldung und Registrierung: ruhige, mittige Karte vor dem
|
||||
* warmen Hintergrundglühen — dasselbe Bild wie im Logo.
|
||||
*/
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link href="/" aria-label="Flimmer">
|
||||
<FlimmerLogo />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<main id="inhalt" className="flex flex-1 items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md">{children}</div>
|
||||
</main>
|
||||
|
||||
<footer className="px-4 pb-8 text-center text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · selbst gehostet
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { isRegistrationOpen } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AuthSwitch, RegisterForm } from "@/components/AuthForm";
|
||||
import { registerAction } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Registrieren" };
|
||||
|
||||
export default async function RegisterPage() {
|
||||
if (await getSessionUser()) redirect("/");
|
||||
|
||||
const [open, userCount] = await Promise.all([isRegistrationOpen(), prisma.user.count()]);
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="fl-card p-6 sm:p-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Registrierung geschlossen</h1>
|
||||
<p className="mt-2 text-sm text-[var(--fl-muted)]">
|
||||
Auf dieser Instanz können sich keine neuen Konten anlegen. Wende dich an die
|
||||
Administration, wenn du einen Zugang brauchst.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<AuthSwitch href="/anmelden">Zur Anmeldung</AuthSwitch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fl-card p-6 sm:p-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Konto anlegen</h1>
|
||||
<p className="mt-1 mb-6 text-sm text-[var(--fl-muted)]">
|
||||
{isFirstUser
|
||||
? "Du bist die erste Person hier — dieses Konto wird automatisch Administrator."
|
||||
: "Lege dir deine eigene Bibliothek an."}
|
||||
</p>
|
||||
|
||||
<RegisterForm action={registerAction} />
|
||||
|
||||
<div className="mt-6">
|
||||
<AuthSwitch href="/anmelden">Schon ein Konto? Zur Anmeldung</AuthSwitch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user