Преглед изворни кода

RHL-009 feat(auth): add ChangePasswordCard component with validation and error handling

Code_Uwe пре 5 дана
родитељ
комит
1ec92fce59
1 измењених фајлова са 300 додато и 0 уклоњено
  1. 300 0
      components/profile/ChangePasswordCard.jsx

+ 300 - 0
components/profile/ChangePasswordCard.jsx

@@ -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>
+	);
+}