10 Commity fd2d4aecb0 ... 35587d80c0

Autor SHA1 Wiadomość Data
  Code_Uwe 35587d80c0 RHL-009 feat(profile): integrate ChangePasswordCard component into ProfilePage 5 dni temu
  Code_Uwe 1ec92fce59 RHL-009 feat(auth): add ChangePasswordCard component with validation and error handling 5 dni temu
  Code_Uwe e5ecf9d554 RHL-009 feat(auth): implement change password API with validation and tests 5 dni temu
  Code_Uwe 3a8958c6d7 RHL-009 feat(auth): implement change password functionality with corresponding tests 5 dni temu
  Code_Uwe 0aeed05255 RHL-009 feat(password-policy): add password policy UI and corresponding tests in German 5 dni temu
  Code_Uwe 4860b95c39 RHL-009 feat(auth): implement password policy validation and testing 5 dni temu
  Code_Uwe 89f6a53b45 RHL-009 feat(tests): add unit tests for toast notification functions 5 dni temu
  Code_Uwe 7c886d9d4c RHL-009 feat(ui): integrate Toaster component for improved notification handling 5 dni temu
  Code_Uwe 1ac693cf86 RHL-009 feat(ui): add Toaster component for enhanced notification support 5 dni temu
  Code_Uwe cf87a48b6e RHL-009 chore(deps): add sonner package for enhanced notification support 5 dni temu

+ 104 - 0
app/api/auth/change-password/route.js

@@ -0,0 +1,104 @@
+import bcrypt from "bcryptjs";
+
+import User from "@/models/user";
+import { getDb } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { validateNewPassword } from "@/lib/auth/passwordPolicy";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+} from "@/lib/api/errors";
+
+export const dynamic = "force-dynamic";
+
+const BCRYPT_SALT_ROUNDS = 12;
+
+export const POST = withErrorHandling(
+	async function POST(request) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		let body;
+		try {
+			body = await request.json();
+		} catch {
+			throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
+		}
+
+		if (!body || typeof body !== "object") {
+			throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
+		}
+
+		const { currentPassword, newPassword } = body;
+
+		const missing = [];
+		if (typeof currentPassword !== "string" || !currentPassword.trim()) {
+			missing.push("currentPassword");
+		}
+		if (typeof newPassword !== "string" || !newPassword.trim()) {
+			missing.push("newPassword");
+		}
+
+		if (missing.length > 0) {
+			throw badRequest(
+				"VALIDATION_MISSING_FIELD",
+				"Missing currentPassword or newPassword",
+				{ fields: missing },
+			);
+		}
+
+		await getDb();
+
+		const user = await User.findById(session.userId).exec();
+
+		// Treat missing users like an invalid session (do not leak anything).
+		if (!user) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		// Defensive: if hash is missing, treat as invalid credentials.
+		if (typeof user.passwordHash !== "string" || !user.passwordHash) {
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
+		}
+
+		const currentMatches = await bcrypt.compare(
+			currentPassword,
+			user.passwordHash,
+		);
+
+		if (!currentMatches) {
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
+		}
+
+		const policyCheck = validateNewPassword({
+			newPassword,
+			currentPassword,
+		});
+
+		if (!policyCheck.ok) {
+			throw badRequest("VALIDATION_WEAK_PASSWORD", "Weak password", {
+				...policyCheck.policy,
+				reasons: policyCheck.reasons,
+			});
+		}
+
+		const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
+
+		user.passwordHash = newHash;
+		user.mustChangePassword = false;
+
+		// Defense-in-depth: invalidate any reset token when password changes.
+		user.passwordResetToken = null;
+		user.passwordResetExpiresAt = null;
+
+		await user.save();
+
+		return json({ ok: true }, 200);
+	},
+	{ logPrefix: "[api/auth/change-password]" },
+);

+ 277 - 0
app/api/auth/change-password/route.test.js

@@ -0,0 +1,277 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+	getDb: vi.fn(),
+}));
+
+vi.mock("@/models/user", () => ({
+	default: {
+		findById: vi.fn(),
+	},
+}));
+
+vi.mock("bcryptjs", () => {
+	const compare = vi.fn();
+	const hash = vi.fn();
+	return {
+		default: { compare, hash },
+		compare,
+		hash,
+	};
+});
+
+import { getSession } from "@/lib/auth/session";
+import { getDb } from "@/lib/db";
+import User from "@/models/user";
+import { compare as bcryptCompare, hash as bcryptHash } from "bcryptjs";
+
+import { POST, dynamic } from "./route.js";
+
+function createRequestStub(body) {
+	return {
+		async json() {
+			return body;
+		},
+	};
+}
+
+describe("POST /api/auth/change-password", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+	});
+
+	it('exports dynamic="force-dynamic"', () => {
+		expect(dynamic).toBe("force-dynamic");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await POST(createRequestStub({}));
+		expect(res.status).toBe(401);
+
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 400 when JSON parsing fails", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const req = {
+			json: vi.fn().mockRejectedValue(new Error("invalid json")),
+		};
+
+		const res = await POST(req);
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_JSON",
+			},
+		});
+	});
+
+	it("returns 400 when body is not an object", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const res = await POST(createRequestStub("nope"));
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_BODY",
+			},
+		});
+	});
+
+	it("returns 400 when fields are missing", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const res = await POST(createRequestStub({ currentPassword: "x" }));
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing currentPassword or newPassword",
+				code: "VALIDATION_MISSING_FIELD",
+				details: { fields: ["newPassword"] },
+			},
+		});
+
+		expect(User.findById).not.toHaveBeenCalled();
+	});
+
+	it("returns 401 when user is not found (treat as invalid session)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(null),
+		});
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "OldPassword123",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 401 when current password is wrong", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			passwordHash: "hash",
+			mustChangePassword: true,
+			passwordResetToken: "tok",
+			passwordResetExpiresAt: new Date(),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(false);
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "wrong",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid credentials",
+				code: "AUTH_INVALID_CREDENTIALS",
+			},
+		});
+
+		expect(bcryptHash).not.toHaveBeenCalled();
+		expect(user.save).not.toHaveBeenCalled();
+	});
+
+	it("returns 400 when new password is weak", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			passwordHash: "hash",
+			mustChangePassword: true,
+			passwordResetToken: "tok",
+			passwordResetExpiresAt: new Date(),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(true);
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "OldPassword123",
+				newPassword: "short",
+			}),
+		);
+
+		expect(res.status).toBe(400);
+
+		const body = await res.json();
+		expect(body.error.code).toBe("VALIDATION_WEAK_PASSWORD");
+		expect(body.error.details).toMatchObject({
+			minLength: 8,
+			requireLetter: true,
+			requireNumber: true,
+		});
+		expect(Array.isArray(body.error.details.reasons)).toBe(true);
+
+		expect(bcryptHash).not.toHaveBeenCalled();
+		expect(user.save).not.toHaveBeenCalled();
+	});
+
+	it("returns 200 and updates passwordHash + clears flags on success", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			passwordHash: "old-hash",
+			mustChangePassword: true,
+			passwordResetToken: "tok",
+			passwordResetExpiresAt: new Date("2030-01-01"),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(true);
+		bcryptHash.mockResolvedValue("new-hash");
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "OldPassword123",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(200);
+		expect(await res.json()).toEqual({ ok: true });
+
+		expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12);
+
+		expect(user.passwordHash).toBe("new-hash");
+		expect(user.mustChangePassword).toBe(false);
+		expect(user.passwordResetToken).toBe(null);
+		expect(user.passwordResetExpiresAt).toBe(null);
+
+		expect(user.save).toHaveBeenCalledTimes(1);
+	});
+});

+ 2 - 7
app/layout.jsx

@@ -1,6 +1,7 @@
 import { Geist, Geist_Mono } from "next/font/google";
 import "./globals.css";
 import { ThemeProvider } from "@/components/ui/theme-provider";
+import { Toaster } from "@/components/ui/sonner";
 
 const geistSans = Geist({
 	variable: "--font-geist-sans",
@@ -24,19 +25,13 @@ export default function RootLayout({ children }) {
 				className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-background text-foreground antialiased`}
 			>
 				<ThemeProvider
-					/*
-						Theme setup (shadcn/ui + next-themes):
-						- attribute="class" means themes are controlled via <html class="dark">
-						- defaultTheme="system" respects the OS preference
-						- enableSystem keeps system sync
-						- disableTransitionOnChange avoids flicker/jumpy transitions
-					*/
 					attribute="class"
 					defaultTheme="system"
 					enableSystem
 					disableTransitionOnChange
 				>
 					{children}
+					<Toaster position="bottom-center" />
 				</ThemeProvider>
 			</body>
 		</html>

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

+ 3 - 19
components/profile/ProfilePage.jsx

@@ -3,6 +3,8 @@
 import React from "react";
 import { useAuth } from "@/components/auth/authContext";
 
+import ChangePasswordCard from "@/components/profile/ChangePasswordCard";
+
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
@@ -96,25 +98,7 @@ export default function ProfilePage() {
 				</CardFooter>
 			</Card>
 
-			<Card>
-				<CardHeader>
-					<CardTitle>Passwort</CardTitle>
-					<CardDescription>
-						Die Passwort-Änderung wird in einem separaten Ticket umgesetzt.
-					</CardDescription>
-				</CardHeader>
-
-				<CardFooter className="flex justify-end">
-					<Button
-						type="button"
-						disabled
-						aria-disabled="true"
-						title="Kommt später"
-					>
-						Passwort ändern
-					</Button>
-				</CardFooter>
-			</Card>
+			<ChangePasswordCard />
 
 			{!isAuthenticated ? (
 				<p className="text-xs text-muted-foreground">

+ 41 - 0
components/ui/sonner.jsx

@@ -0,0 +1,41 @@
+"use client"
+
+import {
+  CircleCheckIcon,
+  InfoIcon,
+  Loader2Icon,
+  OctagonXIcon,
+  TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner } from "sonner";
+
+const Toaster = ({
+  ...props
+}) => {
+  const { theme = "system" } = useTheme()
+
+  return (
+    <Sonner
+      theme={theme}
+      className="toaster group"
+      icons={{
+        success: <CircleCheckIcon className="size-4" />,
+        info: <InfoIcon className="size-4" />,
+        warning: <TriangleAlertIcon className="size-4" />,
+        error: <OctagonXIcon className="size-4" />,
+        loading: <Loader2Icon className="size-4 animate-spin" />,
+      }}
+      style={
+        {
+          "--normal-bg": "var(--popover)",
+          "--normal-text": "var(--popover-foreground)",
+          "--normal-border": "var(--border)",
+          "--border-radius": "var(--radius)"
+        }
+      }
+      {...props} />
+  );
+}
+
+export { Toaster }

+ 66 - 0
lib/auth/passwordPolicy.js

@@ -0,0 +1,66 @@
+export const PASSWORD_POLICY = Object.freeze({
+	minLength: 8,
+	requireLetter: true,
+	requireNumber: true,
+	disallowSameAsCurrent: true,
+});
+
+export const PASSWORD_POLICY_REASON = Object.freeze({
+	MIN_LENGTH: "MIN_LENGTH",
+	MISSING_LETTER: "MISSING_LETTER",
+	MISSING_NUMBER: "MISSING_NUMBER",
+	SAME_AS_CURRENT: "SAME_AS_CURRENT",
+});
+
+function hasLetter(value) {
+	return /[A-Za-z]/.test(String(value || ""));
+}
+
+function hasNumber(value) {
+	return /\d/.test(String(value || ""));
+}
+
+/**
+ * Validate a new password against the project's explicit policy.
+ *
+ * @param {{ newPassword?: unknown, currentPassword?: unknown }} input
+ * @returns {{
+ *   ok: boolean,
+ *   reasons: string[],
+ *   policy: { minLength: number, requireLetter: boolean, requireNumber: boolean, disallowSameAsCurrent: boolean }
+ * }}
+ */
+export function validateNewPassword({ newPassword, currentPassword } = {}) {
+	const pw = typeof newPassword === "string" ? newPassword : "";
+	const current = typeof currentPassword === "string" ? currentPassword : null;
+
+	const reasons = [];
+
+	if (pw.length < PASSWORD_POLICY.minLength) {
+		reasons.push(PASSWORD_POLICY_REASON.MIN_LENGTH);
+	}
+
+	if (PASSWORD_POLICY.requireLetter && !hasLetter(pw)) {
+		reasons.push(PASSWORD_POLICY_REASON.MISSING_LETTER);
+	}
+
+	if (PASSWORD_POLICY.requireNumber && !hasNumber(pw)) {
+		reasons.push(PASSWORD_POLICY_REASON.MISSING_NUMBER);
+	}
+
+	if (
+		PASSWORD_POLICY.disallowSameAsCurrent &&
+		current !== null &&
+		pw === current
+	) {
+		reasons.push(PASSWORD_POLICY_REASON.SAME_AS_CURRENT);
+	}
+
+	const uniqueReasons = Array.from(new Set(reasons));
+
+	return {
+		ok: uniqueReasons.length === 0,
+		reasons: uniqueReasons,
+		policy: { ...PASSWORD_POLICY },
+	};
+}

+ 61 - 0
lib/auth/passwordPolicy.test.js

@@ -0,0 +1,61 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	validateNewPassword,
+	PASSWORD_POLICY,
+	PASSWORD_POLICY_REASON,
+} from "./passwordPolicy.js";
+
+describe("lib/auth/passwordPolicy", () => {
+	it("accepts a strong password", () => {
+		const res = validateNewPassword({
+			newPassword: "StrongPassword123",
+			currentPassword: "OldPassword123",
+		});
+
+		expect(res.ok).toBe(true);
+		expect(res.reasons).toEqual([]);
+		expect(res.policy).toEqual(PASSWORD_POLICY);
+	});
+
+	it("rejects too short passwords", () => {
+		const res = validateNewPassword({
+			newPassword: "Abc1",
+			currentPassword: "OldPassword123",
+		});
+
+		expect(res.ok).toBe(false);
+		expect(res.reasons).toContain(PASSWORD_POLICY_REASON.MIN_LENGTH);
+	});
+
+	it("rejects passwords without numbers", () => {
+		const res = validateNewPassword({
+			newPassword: "VeryStrongPassword",
+			currentPassword: "OldPassword123",
+		});
+
+		expect(res.ok).toBe(false);
+		expect(res.reasons).toContain(PASSWORD_POLICY_REASON.MISSING_NUMBER);
+	});
+
+	it("rejects passwords without letters", () => {
+		const res = validateNewPassword({
+			newPassword: "1234567890123",
+			currentPassword: "OldPassword123",
+		});
+
+		expect(res.ok).toBe(false);
+		expect(res.reasons).toContain(PASSWORD_POLICY_REASON.MISSING_LETTER);
+	});
+
+	it("rejects when new password equals current password", () => {
+		const res = validateNewPassword({
+			newPassword: "SamePassword123",
+			currentPassword: "SamePassword123",
+		});
+
+		expect(res.ok).toBe(false);
+		expect(res.reasons).toContain(PASSWORD_POLICY_REASON.SAME_AS_CURRENT);
+	});
+});

+ 20 - 4
lib/frontend/apiClient.js

@@ -280,6 +280,22 @@ export function getMe(options) {
 	return apiFetch("/api/auth/me", { method: "GET", ...options });
 }
 
+/**
+ * Change password (RHL-009):
+ * - Requires an active session cookie.
+ * - Body: { currentPassword, newPassword }
+ *
+ * @param {{ currentPassword: string, newPassword: string }} input
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function changePassword(input, options) {
+	return apiFetch("/api/auth/change-password", {
+		method: "POST",
+		body: input,
+		...options,
+	});
+}
+
 /**
  * List branches visible to the current session (RBAC is enforced server-side).
  *
@@ -308,9 +324,9 @@ export function getYears(branch, options) {
 export function getMonths(branch, year, options) {
 	return apiFetch(
 		`/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
-			year
+			year,
 		)}/months`,
-		{ method: "GET", ...options }
+		{ method: "GET", ...options },
 	);
 }
 
@@ -323,9 +339,9 @@ export function getMonths(branch, year, options) {
 export function getDays(branch, year, month, options) {
 	return apiFetch(
 		`/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
-			year
+			year,
 		)}/${encodeURIComponent(month)}/days`,
-		{ method: "GET", ...options }
+		{ method: "GET", ...options },
 	);
 }
 

+ 41 - 9
lib/frontend/apiClient.test.js

@@ -8,6 +8,7 @@ import {
 	login,
 	getMe,
 	search,
+	changePassword,
 } from "./apiClient.js";
 
 beforeEach(() => {
@@ -28,7 +29,7 @@ describe("lib/frontend/apiClient", () => {
 			new Response(JSON.stringify({ ok: true }), {
 				status: 200,
 				headers: { "Content-Type": "application/json" },
-			})
+			}),
 		);
 
 		await apiFetch("/api/health");
@@ -46,7 +47,7 @@ describe("lib/frontend/apiClient", () => {
 			new Response(JSON.stringify({ ok: true }), {
 				status: 200,
 				headers: { "Content-Type": "application/json" },
-			})
+			}),
 		);
 
 		await login({ username: "u", password: "p" });
@@ -64,8 +65,8 @@ describe("lib/frontend/apiClient", () => {
 				JSON.stringify({
 					error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
 				}),
-				{ status: 401, headers: { "Content-Type": "application/json" } }
-			)
+				{ status: 401, headers: { "Content-Type": "application/json" } },
+			),
 		);
 
 		await expect(apiFetch("/api/branches")).rejects.toMatchObject({
@@ -99,8 +100,8 @@ describe("lib/frontend/apiClient", () => {
 					day: "23",
 					files: [],
 				}),
-				{ status: 200, headers: { "Content-Type": "application/json" } }
-			)
+				{ status: 200, headers: { "Content-Type": "application/json" } },
+			),
 		);
 
 		await getFiles("NL01", "2024", "10", "23");
@@ -124,7 +125,7 @@ describe("lib/frontend/apiClient", () => {
 			new Response(JSON.stringify({ user: null }), {
 				status: 200,
 				headers: { "Content-Type": "application/json" },
-			})
+			}),
 		);
 
 		const res = await getMe();
@@ -141,12 +142,43 @@ describe("lib/frontend/apiClient", () => {
 		expect(init.cache).toBe("no-store");
 	});
 
+	it("changePassword calls /api/auth/change-password with POST and JSON body", async () => {
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ ok: true }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			}),
+		);
+
+		await changePassword({
+			currentPassword: "OldPassword123",
+			newPassword: "StrongPassword123",
+		});
+
+		expect(fetch).toHaveBeenCalledTimes(1);
+		const [url, init] = fetch.mock.calls[0];
+
+		expect(url).toBe("/api/auth/change-password");
+		expect(init.method).toBe("POST");
+		expect(init.headers.Accept).toBe("application/json");
+		expect(init.headers["Content-Type"]).toBe("application/json");
+		expect(init.body).toBe(
+			JSON.stringify({
+				currentPassword: "OldPassword123",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(init.credentials).toBe("include");
+		expect(init.cache).toBe("no-store");
+	});
+
 	it("search builds the expected query string for branch scope", async () => {
 		fetch.mockResolvedValue(
 			new Response(JSON.stringify({ items: [], nextCursor: null }), {
 				status: 200,
 				headers: { "Content-Type": "application/json" },
-			})
+			}),
 		);
 
 		await search({ q: "bridgestone", branch: "NL01", limit: 100 });
@@ -170,7 +202,7 @@ describe("lib/frontend/apiClient", () => {
 			new Response(JSON.stringify({ items: [], nextCursor: null }), {
 				status: 200,
 				headers: { "Content-Type": "application/json" },
-			})
+			}),
 		);
 
 		await search({

+ 70 - 0
lib/frontend/profile/passwordPolicyUi.js

@@ -0,0 +1,70 @@
+import {
+	PASSWORD_POLICY,
+	PASSWORD_POLICY_REASON,
+} from "@/lib/auth/passwordPolicy";
+
+const ORDERED_REASONS = [
+	PASSWORD_POLICY_REASON.MIN_LENGTH,
+	PASSWORD_POLICY_REASON.MISSING_LETTER,
+	PASSWORD_POLICY_REASON.MISSING_NUMBER,
+	PASSWORD_POLICY_REASON.SAME_AS_CURRENT,
+];
+
+function uniqueStrings(arr) {
+	const list = Array.isArray(arr) ? arr.map(String) : [];
+	return Array.from(new Set(list));
+}
+
+function labelForReasonDe(reason, { minLength } = {}) {
+	const min =
+		Number.isInteger(minLength) && minLength > 0
+			? minLength
+			: PASSWORD_POLICY.minLength;
+
+	if (reason === PASSWORD_POLICY_REASON.MIN_LENGTH) {
+		return `Mindestens ${min} Zeichen`;
+	}
+	if (reason === PASSWORD_POLICY_REASON.MISSING_LETTER) {
+		return "Mindestens 1 Buchstabe (A–Z)";
+	}
+	if (reason === PASSWORD_POLICY_REASON.MISSING_NUMBER) {
+		return "Mindestens 1 Zahl (0–9)";
+	}
+	if (reason === PASSWORD_POLICY_REASON.SAME_AS_CURRENT) {
+		return "Neues Passwort darf nicht identisch sein";
+	}
+	return null;
+}
+
+export function getPasswordPolicyHintLinesDe(policy = PASSWORD_POLICY) {
+	const min =
+		Number.isInteger(policy?.minLength) && policy.minLength > 0
+			? policy.minLength
+			: PASSWORD_POLICY.minLength;
+
+	const lines = [`Mindestens ${min} Zeichen`];
+
+	if (policy?.requireLetter) lines.push("Mindestens 1 Buchstabe (A–Z)");
+	if (policy?.requireNumber) lines.push("Mindestens 1 Zahl (0–9)");
+	if (policy?.disallowSameAsCurrent)
+		lines.push("Darf nicht identisch zum aktuellen Passwort sein");
+
+	return lines;
+}
+
+export function reasonsToHintLinesDe({ reasons, minLength } = {}) {
+	const set = new Set(uniqueStrings(reasons));
+	const ordered = ORDERED_REASONS.filter((r) => set.has(r));
+
+	const lines = ordered
+		.map((r) => labelForReasonDe(r, { minLength }))
+		.filter(Boolean);
+
+	return lines;
+}
+
+export function buildWeakPasswordMessageDe({ reasons, minLength } = {}) {
+	const lines = reasonsToHintLinesDe({ reasons, minLength });
+	if (lines.length === 0) return "Bitte wählen Sie ein stärkeres Passwort.";
+	return lines.join(" • ");
+}

+ 38 - 0
lib/frontend/profile/passwordPolicyUi.test.js

@@ -0,0 +1,38 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	getPasswordPolicyHintLinesDe,
+	reasonsToHintLinesDe,
+	buildWeakPasswordMessageDe,
+} from "./passwordPolicyUi.js";
+
+describe("lib/frontend/profile/passwordPolicyUi", () => {
+	it("returns stable policy hint lines (German)", () => {
+		const lines = getPasswordPolicyHintLinesDe();
+		expect(lines.length).toBeGreaterThanOrEqual(3);
+		expect(lines[0]).toMatch(/Mindestens/i);
+	});
+
+	it("maps ordered reasons to German hint lines", () => {
+		const lines = reasonsToHintLinesDe({
+			reasons: ["MISSING_NUMBER", "MIN_LENGTH"],
+			minLength: 8,
+		});
+
+		expect(lines).toEqual(["Mindestens 8 Zeichen", "Mindestens 1 Zahl (0–9)"]);
+	});
+
+	it("buildWeakPasswordMessageDe joins hint lines with separators", () => {
+		const msg = buildWeakPasswordMessageDe({
+			reasons: ["MIN_LENGTH", "MISSING_LETTER"],
+			minLength: 8,
+		});
+
+		expect(msg).toBe("Mindestens 8 Zeichen • Mindestens 1 Buchstabe (A–Z)");
+	});
+
+	it("returns a generic message when reasons are missing", () => {
+		expect(buildWeakPasswordMessageDe({ reasons: null })).toMatch(/stärkeres/i);
+	});
+});

+ 127 - 0
lib/frontend/ui/toast.js

@@ -0,0 +1,127 @@
+import { toast } from "sonner";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+
+function normalizeText(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim();
+	return s ? s : null;
+}
+
+function buildToastOptions({ description, ...rest }) {
+	const opts = { ...rest };
+
+	const desc = normalizeText(description);
+	if (desc) opts.description = desc;
+
+	return opts;
+}
+
+export function notifySuccess({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.success(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyError({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.error(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyInfo({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.info(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyWarning({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.warning(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyLoading({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.loading(t, buildToastOptions({ description, ...options }));
+}
+
+export function dismissToast(toastId) {
+	return toast.dismiss(toastId);
+}
+
+export function mapApiErrorToToast(
+	err,
+	{
+		overrides = null,
+		fallbackTitle = "Fehler",
+		fallbackDescription = "Bitte versuchen Sie es erneut.",
+	} = {},
+) {
+	// Allow call sites (like Change Password) to override messages per error code.
+	if (err instanceof ApiClientError) {
+		const code = String(err.code || "");
+
+		if (overrides && overrides[code]) {
+			const o = overrides[code] || {};
+			return {
+				title: normalizeText(o.title) || fallbackTitle,
+				description: normalizeText(o.description) || null,
+			};
+		}
+
+		if (code === "CLIENT_NETWORK_ERROR") {
+			return {
+				title: "Netzwerkfehler",
+				description:
+					"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+			};
+		}
+
+		if (code === "AUTH_UNAUTHENTICATED") {
+			return {
+				title: "Sitzung abgelaufen",
+				description: "Bitte melden Sie sich erneut an.",
+			};
+		}
+
+		if (code.startsWith("VALIDATION_")) {
+			return {
+				title: "Ungültige Eingabe",
+				description:
+					"Bitte prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
+			};
+		}
+	}
+
+	return {
+		title: normalizeText(fallbackTitle) || "Fehler",
+		description: normalizeText(fallbackDescription) || null,
+	};
+}
+
+export function notifyApiError(
+	err,
+	{
+		overrides = null,
+		fallbackTitle = "Fehler",
+		fallbackDescription = "Bitte versuchen Sie es erneut.",
+		...toastOptions
+	} = {},
+) {
+	const mapped = mapApiErrorToToast(err, {
+		overrides,
+		fallbackTitle,
+		fallbackDescription,
+	});
+
+	return toast.error(
+		mapped.title,
+		buildToastOptions({ description: mapped.description, ...toastOptions }),
+	);
+}

+ 158 - 0
lib/frontend/ui/toast.test.js

@@ -0,0 +1,158 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("sonner", () => {
+	const api = {
+		success: vi.fn(() => "toast-success-id"),
+		error: vi.fn(() => "toast-error-id"),
+		info: vi.fn(() => "toast-info-id"),
+		warning: vi.fn(() => "toast-warning-id"),
+		loading: vi.fn(() => "toast-loading-id"),
+		dismiss: vi.fn(() => undefined),
+	};
+
+	return { toast: api };
+});
+
+import { toast } from "sonner";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+
+import {
+	notifySuccess,
+	notifyError,
+	notifyInfo,
+	notifyWarning,
+	notifyLoading,
+	dismissToast,
+	mapApiErrorToToast,
+	notifyApiError,
+} from "./toast.js";
+
+describe("lib/frontend/ui/toast", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+	});
+
+	it("notifySuccess calls toast.success with title + description", () => {
+		const id = notifySuccess({
+			title: "Gespeichert",
+			description: "Änderungen wurden übernommen.",
+		});
+
+		expect(id).toBe("toast-success-id");
+		expect(toast.success).toHaveBeenCalledWith("Gespeichert", {
+			description: "Änderungen wurden übernommen.",
+		});
+	});
+
+	it("notifyError calls toast.error", () => {
+		const id = notifyError({ title: "Fehler", description: "Oh nein." });
+
+		expect(id).toBe("toast-error-id");
+		expect(toast.error).toHaveBeenCalledWith("Fehler", {
+			description: "Oh nein.",
+		});
+	});
+
+	it("notifyInfo / notifyWarning / notifyLoading forward correctly", () => {
+		notifyInfo({ title: "Info", description: "Hinweis" });
+		expect(toast.info).toHaveBeenCalledWith("Info", { description: "Hinweis" });
+
+		notifyWarning({ title: "Warnung" });
+		expect(toast.warning).toHaveBeenCalledWith("Warnung", {});
+
+		notifyLoading({ title: "Lädt…" });
+		expect(toast.loading).toHaveBeenCalledWith("Lädt…", {});
+	});
+
+	it("dismissToast forwards to toast.dismiss", () => {
+		dismissToast("x");
+		expect(toast.dismiss).toHaveBeenCalledWith("x");
+	});
+
+	it("mapApiErrorToToast maps network errors", () => {
+		const err = new ApiClientError({
+			status: 0,
+			code: "CLIENT_NETWORK_ERROR",
+			message: "Network error",
+		});
+
+		expect(mapApiErrorToToast(err)).toEqual({
+			title: "Netzwerkfehler",
+			description:
+				"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+		});
+	});
+
+	it("mapApiErrorToToast maps unauthenticated", () => {
+		const err = new ApiClientError({
+			status: 401,
+			code: "AUTH_UNAUTHENTICATED",
+			message: "Unauthorized",
+		});
+
+		expect(mapApiErrorToToast(err)).toEqual({
+			title: "Sitzung abgelaufen",
+			description: "Bitte melden Sie sich erneut an.",
+		});
+	});
+
+	it("mapApiErrorToToast maps validation errors generically", () => {
+		const err = new ApiClientError({
+			status: 400,
+			code: "VALIDATION_WEAK_PASSWORD",
+			message: "Weak password",
+		});
+
+		expect(mapApiErrorToToast(err)).toEqual({
+			title: "Ungültige Eingabe",
+			description:
+				"Bitte prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
+		});
+	});
+
+	it("mapApiErrorToToast supports overrides by error code", () => {
+		const err = new ApiClientError({
+			status: 401,
+			code: "AUTH_INVALID_CREDENTIALS",
+			message: "Invalid credentials",
+		});
+
+		const mapped = mapApiErrorToToast(err, {
+			overrides: {
+				AUTH_INVALID_CREDENTIALS: {
+					title: "Aktuelles Passwort ist falsch.",
+					description: null,
+				},
+			},
+		});
+
+		expect(mapped).toEqual({
+			title: "Aktuelles Passwort ist falsch.",
+			description: null,
+		});
+	});
+
+	it("notifyApiError uses toast.error with mapped copy", () => {
+		const err = new ApiClientError({
+			status: 0,
+			code: "CLIENT_NETWORK_ERROR",
+			message: "Network error",
+		});
+
+		const id = notifyApiError(err);
+
+		expect(id).toBe("toast-error-id");
+		expect(toast.error).toHaveBeenCalledWith("Netzwerkfehler", {
+			description:
+				"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+		});
+	});
+
+	it("notifySuccess returns null and does not toast when title is missing", () => {
+		const id = notifySuccess({ title: "   " });
+		expect(id).toBe(null);
+		expect(toast.success).not.toHaveBeenCalled();
+	});
+});

+ 11 - 0
package-lock.json

@@ -29,6 +29,7 @@
 				"react": "19.2.0",
 				"react-day-picker": "^9.13.0",
 				"react-dom": "19.2.0",
+				"sonner": "^2.0.7",
 				"tailwind-merge": "^3.4.0"
 			},
 			"devDependencies": {
@@ -8111,6 +8112,16 @@
 			"dev": true,
 			"license": "ISC"
 		},
+		"node_modules/sonner": {
+			"version": "2.0.7",
+			"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+			"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+			"license": "MIT",
+			"peerDependencies": {
+				"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+				"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+			}
+		},
 		"node_modules/source-map-js": {
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

+ 1 - 0
package.json

@@ -32,6 +32,7 @@
 		"react": "19.2.0",
 		"react-day-picker": "^9.13.0",
 		"react-dom": "19.2.0",
+		"sonner": "^2.0.7",
 		"tailwind-merge": "^3.4.0"
 	},
 	"devDependencies": {