feat(PROJ-28): Self-Service Onboarding — Signup, Verify, Password Reset, Invites

- internal/mailer: SMTP-Out via net/smtp (TLS + STARTTLS), HTML+Text-Templates
- internal/tokenstore: auth_tokens Tabelle, SHA-256-Hash, TTL, einmalig verwendbar
- userstore: CreateInactive(), Activate(), GetByEmail(), SetPassword()
- API: POST /signup, GET /verify, POST /forgot-password, POST /reset-password
- API: POST /admin/invite (domain_admin+), GET /auth/invite?token (check)
- Login-Seite: Links zu "Passwort vergessen" und "Registrieren"
- Frontend: /signup, /verify, /forgot-password, /reset-password Seiten
- server.fqdn nicht konfiguriert → Startup-Warnung, Self-Service deaktiviert
- LDAP-Nutzer: Passwort-Reset abgewiesen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 21:54:11 +02:00
co-authored by Claude Sonnet 4.6
parent 7930b85cde
commit 4583262ea4
13 changed files with 1232 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
function VerifyContent() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Kein Token angegeben.");
return;
}
fetch(`/api/auth/verify?token=${encodeURIComponent(token)}`, { credentials: "include" })
.then(async (res) => {
const data = await res.json().catch(() => ({}));
if (res.ok) {
setStatus("ok");
setMessage((data as { message?: string }).message ?? "E-Mail bestätigt.");
} else {
setStatus("error");
setMessage((data as { error?: string }).error ?? "Ungültiger oder abgelaufener Link.");
}
})
.catch(() => {
setStatus("error");
setMessage("Netzwerkfehler. Bitte versuche es erneut.");
});
}, [token]);
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle>{status === "ok" ? "E-Mail bestätigt" : status === "error" ? "Fehler" : "Bestätigen..."}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
{status === "loading" && <p className="text-sm text-muted-foreground">Bitte warten...</p>}
{status !== "loading" && <p className="text-sm">{message}</p>}
{status === "ok" && (
<Button className="w-full" onClick={() => window.location.href = "/"}>
Zur Anmeldung
</Button>
)}
</CardContent>
</Card>
);
}
export default function VerifyPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Suspense>
<VerifyContent />
</Suspense>
</div>
);
}