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
parent 7930b85cde
commit 4583262ea4
13 changed files with 1232 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok && res.status === 503) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? "E-Mail-Versand nicht verfügbar.");
} else {
setDone(true);
}
} catch {
setError("Netzwerkfehler. Bitte versuche es erneut.");
} finally {
setLoading(false);
}
};
if (done) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle>E-Mail gesendet</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-sm text-muted-foreground">
<p>Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet. Bitte prüfe deinen Posteingang.</p>
<a href="/" className="text-sm underline hover:text-foreground">Zur Anmeldung</a>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Passwort vergessen</CardTitle>
<p className="text-sm text-muted-foreground">Wir senden dir einen Reset-Link per E-Mail.</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
placeholder="deine@email.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Senden..." : "Reset-Link senden"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
<a href="/" className="underline hover:text-foreground">Zurück zur Anmeldung</a>
</p>
</CardContent>
</Card>
</div>
);
}
+8
View File
@@ -86,6 +86,14 @@ export default function LoginPage() {
{loading ? "Anmelden..." : "Anmelden"}
</Button>
</form>
<div className="mt-4 flex flex-col items-center gap-2 text-sm text-muted-foreground">
<a href="/forgot-password" className="hover:text-foreground underline">
Passwort vergessen?
</a>
<a href="/signup" className="hover:text-foreground underline">
Noch kein Account? Registrieren
</a>
</div>
</CardContent>
</Card>
</div>
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function ResetForm() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Passwörter stimmen nicht überein.");
return;
}
if (password.length < 8) {
setError("Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? "Fehler beim Zurücksetzen.");
} else {
setDone(true);
}
} catch {
setError("Netzwerkfehler. Bitte versuche es erneut.");
} finally {
setLoading(false);
}
};
if (!token) {
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center"><CardTitle>Fehler</CardTitle></CardHeader>
<CardContent className="text-center text-sm text-destructive">
Kein Reset-Token angegeben.
<div className="mt-4"><a href="/forgot-password" className="underline hover:text-foreground">Neuen Link anfordern</a></div>
</CardContent>
</Card>
);
}
if (done) {
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center"><CardTitle>Passwort geändert</CardTitle></CardHeader>
<CardContent className="space-y-4 text-center text-sm text-muted-foreground">
<p>Dein Passwort wurde erfolgreich zurückgesetzt.</p>
<Button className="w-full" onClick={() => window.location.href = "/"}>Zur Anmeldung</Button>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Neues Passwort</CardTitle>
<p className="text-sm text-muted-foreground">Gib dein neues Passwort ein.</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Neues Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Passwort bestätigen</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Speichern..." : "Passwort ändern"}
</Button>
</form>
</CardContent>
</Card>
);
}
export default function ResetPasswordPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Suspense>
<ResetForm />
</Suspense>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
async function checkInvite(token: string): Promise<string | null> {
const res = await fetch(`/api/auth/invite?token=${encodeURIComponent(token)}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json();
return data.tenant_name ?? null;
}
async function signup(body: {
username: string;
email: string;
password: string;
invite?: string;
}): Promise<void> {
const res = await fetch("/api/auth/signup", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? "Registrierung fehlgeschlagen");
}
}
function SignupForm() {
const router = useRouter();
const params = useSearchParams();
const invite = params.get("invite") ?? "";
const [tenantName, setTenantName] = useState<string | null>(null);
const [inviteError, setInviteError] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!invite) return;
checkInvite(invite).then((name) => {
if (name === null) setInviteError("Ungültiger oder abgelaufener Einladungslink.");
else setTenantName(name);
});
}, [invite]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await signup({ username, email, password, invite: invite || undefined });
setDone(true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
} finally {
setLoading(false);
}
};
if (done) {
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle>Fast geschafft!</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-sm text-muted-foreground">
<p>Wir haben dir eine Bestätigungs-E-Mail gesendet. Bitte klicke auf den Link darin, um deinen Account zu aktivieren.</p>
<Button variant="outline" className="w-full" onClick={() => router.push("/")}>
Zur Anmeldung
</Button>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Registrieren</CardTitle>
{tenantName && (
<p className="text-sm text-muted-foreground">Einladung zu: <strong>{tenantName}</strong></p>
)}
</CardHeader>
<CardContent>
{inviteError && <p className="text-sm text-destructive mb-4">{inviteError}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Registrierung..." : "Account erstellen"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
<a href="/" className="underline hover:text-foreground">Zur Anmeldung</a>
</p>
</CardContent>
</Card>
);
}
export default function SignupPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Suspense>
<SignupForm />
</Suspense>
</div>
);
}
+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>
);
}