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:
Florian Wilde
2026-09-01 18:46:27 +02:00
parent 3e11e4ffb1
commit 01c0f9bc9f
10 changed files with 812 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import Link from "next/link";
import type { AuthFormState } from "@/app/(auth)/actions";
type Action = (state: AuthFormState, formData: FormData) => Promise<AuthFormState>;
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" className="fl-btn fl-btn-primary w-full" disabled={pending}>
{pending ? "Einen Moment …" : label}
</button>
);
}
function Field({
id,
label,
type = "text",
autoComplete,
required = true,
hint,
}: {
id: string;
label: string;
type?: string;
autoComplete?: string;
required?: boolean;
hint?: string;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
{label}
</label>
<input
id={id}
name={id}
type={type}
autoComplete={autoComplete}
required={required}
className="fl-input"
aria-describedby={hint ? `${id}-hint` : undefined}
/>
{hint && (
<p id={`${id}-hint`} className="text-xs text-[var(--fl-faint)]">
{hint}
</p>
)}
</div>
);
}
export function LoginForm({ action, weiter }: { action: Action; weiter?: string }) {
const [state, formAction] = useActionState(action, {} as AuthFormState);
return (
<form action={formAction} className="space-y-4">
{weiter && <input type="hidden" name="weiter" value={weiter} />}
<Field id="email" label="E-Mail-Adresse" type="email" autoComplete="email" />
<Field id="password" label="Passwort" type="password" autoComplete="current-password" />
<FormError message={state.error} />
<SubmitButton label="Anmelden" />
</form>
);
}
export function RegisterForm({ action }: { action: Action }) {
const [state, formAction] = useActionState(action, {} as AuthFormState);
return (
<form action={formAction} className="space-y-4">
<Field id="name" label="Anzeigename" autoComplete="name" />
<Field id="email" label="E-Mail-Adresse" type="email" autoComplete="email" />
<Field
id="password"
label="Passwort"
type="password"
autoComplete="new-password"
hint="Mindestens 10 Zeichen. Länge hilft mehr als Sonderzeichen."
/>
<Field
id="passwordRepeat"
label="Passwort wiederholen"
type="password"
autoComplete="new-password"
/>
<FormError message={state.error} />
<SubmitButton label="Konto anlegen" />
</form>
);
}
export function FormError({ message }: { message?: string }) {
if (!message) return null;
return (
<p
role="alert"
className="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)]"
>
{message}
</p>
);
}
export function AuthSwitch({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className="block text-center text-sm text-[var(--fl-muted)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
{children}
</Link>
);
}