feat: add password recovery and everyday workflow improvements

This commit is contained in:
t.indorf
2026-10-12 17:51:50 +02:00
parent 061024d901
commit 661776e609
19 changed files with 1721 additions and 141 deletions
+117
View File
@@ -0,0 +1,117 @@
"use server";
import { z } from "zod";
import { after } from "next/server";
import { limitPublicAction } from "@/lib/rate-limit";
import {
requestPasswordReset,
passwordResetMessage,
resetPassword,
changeOwnPassword,
} from "@/lib/password-management";
import { hashToken } from "@/lib/tokens";
import { requireSession } from "@/lib/auth-utils";
import { publicActionError } from "@/lib/action-error";
export type PasswordActionState = {
error?: string;
message?: string;
success?: boolean;
};
export async function requestResetAction(
_previous: PasswordActionState,
form: FormData,
): Promise<PasswordActionState> {
const email = z
.string()
.trim()
.max(254)
.email()
.toLowerCase()
.safeParse(form.get("email"));
if (!email.success)
return { error: "Bitte gib eine gültige E-Mail-Adresse ein." };
try {
if (await limitPublicAction("password-reset-request", email.data, 3, 3600))
after(async () => {
try {
await requestPasswordReset(email.data);
} catch {
console.error(
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
);
}
});
} catch {
console.error(
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
);
}
return { success: true, message: passwordResetMessage };
}
export async function finishResetAction(
_previous: PasswordActionState,
form: FormData,
): Promise<PasswordActionState> {
const token = form.get("token");
if (typeof token !== "string" || !/^[a-f0-9]{64}$/i.test(token))
return {
error: "Dieser Link ist ungültig. Bitte fordere einen neuen Link an.",
};
try {
if (
!(await limitPublicAction(
"password-reset-complete",
hashToken(token),
5,
900,
))
)
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
await resetPassword({
token,
password: form.get("password"),
confirmation: form.get("confirmation"),
});
return {
success: true,
message:
"Dein Passwort wurde geändert. Melde dich mit deinem neuen Passwort an.",
};
} catch (error) {
return {
error: publicActionError(
error,
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
),
};
}
}
export async function changePasswordAction(
_previous: PasswordActionState,
form: FormData,
): Promise<PasswordActionState> {
const { user } = await requireSession();
try {
if (!(await limitPublicAction("password-change", user.id, 5, 900)))
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
await changeOwnPassword(
{ id: user.id, sessionVersion: user.sessionVersion },
{
currentPassword: form.get("currentPassword"),
password: form.get("password"),
confirmation: form.get("confirmation"),
},
);
return {
success: true,
message: "Dein Passwort wurde geändert. Bitte melde dich erneut an.",
};
} catch (error) {
return {
error: publicActionError(
error,
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
),
};
}
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PasswordForm } from "./password-form";
export const metadata = {
title: "Passwort vergessen · Kita-Planer",
robots: { index: false, follow: false },
};
export default function ForgotPasswordPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Passwort vergessen?</CardTitle>
<CardDescription>
Fordere einen persönlichen Link an, um ein neues Passwort
festzulegen. Der Link ist 30 Minuten gültig.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<PasswordForm mode="request" />
<Link
href="/login"
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
>
Zurück zur Anmeldung
</Link>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useId } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
requestResetAction,
finishResetAction,
changePasswordAction,
type PasswordActionState,
} from "./actions";
export function PasswordForm({
mode,
token,
}: {
mode: "request" | "reset" | "change";
token?: string;
}) {
const action =
mode === "request"
? requestResetAction
: mode === "reset"
? finishResetAction
: changePasswordAction;
const [state, formAction, pending] = useActionState(
action,
{} as PasswordActionState,
);
const id = useId();
useEffect(() => {
if (mode === "change" && state.success)
window.location.replace(
new URL("/login?passwordChanged=1", window.location.origin).href,
);
}, [mode, state.success]);
if (state.success)
return (
<div className="space-y-4">
<p
role="status"
className="rounded-xl border border-[var(--hairline)] bg-[var(--good-soft)] p-4 text-sm leading-6 text-[var(--ink)]"
>
{state.message}
</p>
{mode !== "request" ? (
<Link
href="/login"
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
>
Zur Anmeldung
</Link>
) : null}
</div>
);
return (
<form action={formAction} className="space-y-4">
{token ? <input type="hidden" name="token" value={token} /> : null}
{mode === "request" ? (
<div className="space-y-2">
<Label htmlFor={`${id}-email`}>E-Mail-Adresse</Label>
<Input
id={`${id}-email`}
name="email"
type="email"
autoComplete="email"
maxLength={254}
required
disabled={pending}
/>
</div>
) : (
<>
{mode === "change" ? (
<div className="space-y-2">
<Label htmlFor={`${id}-current`}>Aktuelles Passwort</Label>
<Input
id={`${id}-current`}
name="currentPassword"
type="password"
autoComplete="current-password"
required
disabled={pending}
/>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`${id}-password`}>Neues Passwort</Label>
<Input
id={`${id}-password`}
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
disabled={pending}
/>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Mindestens 8 Zeichen. Verwende ein eigenes Passwort für den
Kita-Planer.
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-confirmation`}>
Neues Passwort wiederholen
</Label>
<Input
id={`${id}-confirmation`}
name="confirmation"
type="password"
autoComplete="new-password"
minLength={8}
required
disabled={pending}
/>
</div>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Nach dem Speichern werden alle bisherigen Anmeldungen beendet.
</p>
</>
)}
{state.error ? (
<p role="alert" className="text-sm leading-6 text-[var(--danger)]">
{state.error}
</p>
) : null}
<Button type="submit" className="w-full sm:w-auto" disabled={pending}>
{pending
? "Wird verarbeitet …"
: mode === "request"
? "Link anfordern"
: "Passwort speichern"}
</Button>
</form>
);
}