|
|
@@ -0,0 +1,300 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import React from "react";
|
|
|
+
|
|
|
+import { useAuth } from "@/components/auth/authContext";
|
|
|
+import { changePassword, ApiClientError } from "@/lib/frontend/apiClient";
|
|
|
+import {
|
|
|
+ notifyError,
|
|
|
+ notifySuccess,
|
|
|
+ notifyApiError,
|
|
|
+} from "@/lib/frontend/ui/toast";
|
|
|
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
|
|
|
+import {
|
|
|
+ getPasswordPolicyHintLinesDe,
|
|
|
+ reasonsToHintLinesDe,
|
|
|
+ buildWeakPasswordMessageDe,
|
|
|
+} from "@/lib/frontend/profile/passwordPolicyUi";
|
|
|
+
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
+import { Input } from "@/components/ui/input";
|
|
|
+import { Label } from "@/components/ui/label";
|
|
|
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
|
|
+import {
|
|
|
+ Card,
|
|
|
+ CardHeader,
|
|
|
+ CardTitle,
|
|
|
+ CardDescription,
|
|
|
+ CardContent,
|
|
|
+ CardFooter,
|
|
|
+} from "@/components/ui/card";
|
|
|
+
|
|
|
+function isNonEmptyString(value) {
|
|
|
+ return typeof value === "string" && value.trim().length > 0;
|
|
|
+}
|
|
|
+
|
|
|
+export default function ChangePasswordCard() {
|
|
|
+ const { status, user } = useAuth();
|
|
|
+ const isAuthenticated = status === "authenticated" && user;
|
|
|
+
|
|
|
+ const [currentPassword, setCurrentPassword] = React.useState("");
|
|
|
+ const [newPassword, setNewPassword] = React.useState("");
|
|
|
+ const [confirmNewPassword, setConfirmNewPassword] = React.useState("");
|
|
|
+
|
|
|
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
|
+
|
|
|
+ const [error, setError] = React.useState(null);
|
|
|
+ // error: { title: string, description?: string|null, field?: string|null, hints?: string[]|null }
|
|
|
+
|
|
|
+ const policyLines = React.useMemo(() => {
|
|
|
+ return getPasswordPolicyHintLinesDe();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ function clearForm() {
|
|
|
+ setCurrentPassword("");
|
|
|
+ setNewPassword("");
|
|
|
+ setConfirmNewPassword("");
|
|
|
+ }
|
|
|
+
|
|
|
+ function redirectToLoginExpired() {
|
|
|
+ const next =
|
|
|
+ typeof window !== "undefined"
|
|
|
+ ? `${window.location.pathname}${window.location.search}`
|
|
|
+ : "/profile";
|
|
|
+
|
|
|
+ window.location.replace(
|
|
|
+ buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onSubmit(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ if (isSubmitting) return;
|
|
|
+
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ if (!isAuthenticated) {
|
|
|
+ notifyError({
|
|
|
+ title: "Nicht angemeldet",
|
|
|
+ description: "Bitte melden Sie sich an, um Ihr Passwort zu ändern.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isNonEmptyString(currentPassword)) {
|
|
|
+ setError({
|
|
|
+ field: "currentPassword",
|
|
|
+ title: "Bitte aktuelles Passwort eingeben.",
|
|
|
+ description: null,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isNonEmptyString(newPassword)) {
|
|
|
+ setError({
|
|
|
+ field: "newPassword",
|
|
|
+ title: "Bitte ein neues Passwort eingeben.",
|
|
|
+ description: null,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isNonEmptyString(confirmNewPassword)) {
|
|
|
+ setError({
|
|
|
+ field: "confirmNewPassword",
|
|
|
+ title: "Bitte neues Passwort bestätigen.",
|
|
|
+ description: null,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (newPassword !== confirmNewPassword) {
|
|
|
+ setError({
|
|
|
+ field: "confirmNewPassword",
|
|
|
+ title: "Passwörter stimmen nicht überein.",
|
|
|
+ description: "Bitte prüfen Sie die Bestätigung.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (newPassword === currentPassword) {
|
|
|
+ setError({
|
|
|
+ field: "newPassword",
|
|
|
+ title: "Neues Passwort ist ungültig.",
|
|
|
+ description:
|
|
|
+ "Neues Passwort darf nicht identisch zum aktuellen Passwort sein.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsSubmitting(true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ await changePassword({
|
|
|
+ currentPassword,
|
|
|
+ newPassword,
|
|
|
+ });
|
|
|
+
|
|
|
+ clearForm();
|
|
|
+
|
|
|
+ notifySuccess({
|
|
|
+ title: "Passwort geändert",
|
|
|
+ description: "Ihr Passwort wurde erfolgreich aktualisiert.",
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ if (err instanceof ApiClientError) {
|
|
|
+ if (err.code === "AUTH_UNAUTHENTICATED") {
|
|
|
+ notifyApiError(err);
|
|
|
+ redirectToLoginExpired();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (err.code === "AUTH_INVALID_CREDENTIALS") {
|
|
|
+ const title = "Aktuelles Passwort ist falsch.";
|
|
|
+ setError({ field: "currentPassword", title, description: null });
|
|
|
+ notifyError({ title });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (err.code === "VALIDATION_WEAK_PASSWORD") {
|
|
|
+ const reasons = err.details?.reasons;
|
|
|
+ const minLength = err.details?.minLength;
|
|
|
+
|
|
|
+ const hints = reasonsToHintLinesDe({ reasons, minLength });
|
|
|
+ const description = buildWeakPasswordMessageDe({
|
|
|
+ reasons,
|
|
|
+ minLength,
|
|
|
+ });
|
|
|
+
|
|
|
+ setError({
|
|
|
+ field: "newPassword",
|
|
|
+ title: "Neues Passwort ist zu schwach.",
|
|
|
+ description,
|
|
|
+ hints,
|
|
|
+ });
|
|
|
+
|
|
|
+ notifyError({
|
|
|
+ title: "Neues Passwort ist zu schwach.",
|
|
|
+ description,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setError({
|
|
|
+ field: null,
|
|
|
+ title: "Passwort konnte nicht geändert werden.",
|
|
|
+ description: "Bitte versuchen Sie es erneut.",
|
|
|
+ });
|
|
|
+
|
|
|
+ notifyApiError(err, {
|
|
|
+ fallbackTitle: "Passwort konnte nicht geändert werden.",
|
|
|
+ fallbackDescription: "Bitte versuchen Sie es erneut.",
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ setIsSubmitting(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const showError = Boolean(error && error.title);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>Passwort</CardTitle>
|
|
|
+ <CardDescription>Ändern Sie Ihr Passwort.</CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ {!isAuthenticated ? (
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ Hinweis: Passwortänderungen sind nur verfügbar, wenn Sie angemeldet
|
|
|
+ sind.
|
|
|
+ </p>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {showError ? (
|
|
|
+ <Alert variant="destructive">
|
|
|
+ <AlertTitle>{error.title}</AlertTitle>
|
|
|
+ {error.description ? (
|
|
|
+ <AlertDescription>{error.description}</AlertDescription>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {Array.isArray(error.hints) && error.hints.length > 0 ? (
|
|
|
+ <AlertDescription>
|
|
|
+ <ul className="mt-2 list-disc pl-5">
|
|
|
+ {error.hints.map((line) => (
|
|
|
+ <li key={line}>{line}</li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ </AlertDescription>
|
|
|
+ ) : null}
|
|
|
+ </Alert>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ <form onSubmit={onSubmit} className="space-y-4">
|
|
|
+ <div className="grid gap-2">
|
|
|
+ <Label htmlFor="currentPassword">Aktuelles Passwort</Label>
|
|
|
+ <Input
|
|
|
+ id="currentPassword"
|
|
|
+ type="password"
|
|
|
+ autoComplete="current-password"
|
|
|
+ value={currentPassword}
|
|
|
+ onChange={(e) => setCurrentPassword(e.target.value)}
|
|
|
+ disabled={!isAuthenticated || isSubmitting}
|
|
|
+ aria-invalid={
|
|
|
+ error?.field === "currentPassword" ? "true" : "false"
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid gap-2">
|
|
|
+ <Label htmlFor="newPassword">Neues Passwort</Label>
|
|
|
+ <Input
|
|
|
+ id="newPassword"
|
|
|
+ type="password"
|
|
|
+ autoComplete="new-password"
|
|
|
+ value={newPassword}
|
|
|
+ onChange={(e) => setNewPassword(e.target.value)}
|
|
|
+ disabled={!isAuthenticated || isSubmitting}
|
|
|
+ aria-invalid={error?.field === "newPassword" ? "true" : "false"}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ul className="mt-1 list-disc pl-5 text-xs text-muted-foreground">
|
|
|
+ {policyLines.map((line) => (
|
|
|
+ <li key={line}>{line}</li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid gap-2">
|
|
|
+ <Label htmlFor="confirmNewPassword">
|
|
|
+ Neues Passwort bestätigen
|
|
|
+ </Label>
|
|
|
+ <Input
|
|
|
+ id="confirmNewPassword"
|
|
|
+ type="password"
|
|
|
+ autoComplete="new-password"
|
|
|
+ value={confirmNewPassword}
|
|
|
+ onChange={(e) => setConfirmNewPassword(e.target.value)}
|
|
|
+ disabled={!isAuthenticated || isSubmitting}
|
|
|
+ aria-invalid={
|
|
|
+ error?.field === "confirmNewPassword" ? "true" : "false"
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <CardFooter className="p-0 flex justify-end">
|
|
|
+ <Button
|
|
|
+ type="submit"
|
|
|
+ disabled={!isAuthenticated || isSubmitting}
|
|
|
+ title={!isAuthenticated ? "Bitte anmelden" : "Passwort ändern"}
|
|
|
+ >
|
|
|
+ {isSubmitting ? "Speichern…" : "Passwort ändern"}
|
|
|
+ </Button>
|
|
|
+ </CardFooter>
|
|
|
+ </form>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ );
|
|
|
+}
|