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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user