feat: add password recovery and everyday workflow improvements
This commit is contained in:
@@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user