diff --git a/src/app/(private)/change-password/page.tsx b/src/app/(private)/change-password/page.tsx
new file mode 100644
index 00000000..581cb75b
--- /dev/null
+++ b/src/app/(private)/change-password/page.tsx
@@ -0,0 +1,10 @@
+import { ChangePasswordForm } from "@/features/password-change";
+
+export default function ChangePasswordPage() {
+ return (
+
+
Tutaj zmienisz hasło
+
+
+ );
+}
diff --git a/src/components/presentation/navbar.tsx b/src/components/presentation/navbar.tsx
index 25be6376..77cfdf3e 100644
--- a/src/components/presentation/navbar.tsx
+++ b/src/components/presentation/navbar.tsx
@@ -43,6 +43,10 @@ function UserProfileMenu({ user }: { user: User }) {
+
+
+ Zmień hasło
+
);
diff --git a/src/features/backend/utils/handle-response.ts b/src/features/backend/utils/handle-response.ts
index e761863c..887e1005 100644
--- a/src/features/backend/utils/handle-response.ts
+++ b/src/features/backend/utils/handle-response.ts
@@ -30,7 +30,13 @@ export async function handleResponse(
};
const code = errorReport?.error.code ?? String(response.status);
const errorMessage = `Request failed with code ${code}: ${message}`;
- logger.error(
+
+ const isValidationError =
+ code === "E_VALIDATION_ERROR" ||
+ Array.isArray(errorReport?.error.validationIssues);
+ const logFunction = isValidationError ? logger.warn : logger.error;
+
+ logFunction(
{
url: request.url,
method: request.method,
diff --git a/src/features/password-change/api/change-password.ts b/src/features/password-change/api/change-password.ts
new file mode 100644
index 00000000..5477d190
--- /dev/null
+++ b/src/features/password-change/api/change-password.ts
@@ -0,0 +1,22 @@
+import { fetchMutation } from "@/features/backend";
+import type { MessageResponse } from "@/features/backend/types";
+
+/**
+ * Calls POST /api/v1/auth/change_password
+ * Body: { oldPassword, newPassword, newPasswordConfirm }
+ * Requires authentication; fetchMutation should attach tokens automatically
+ */
+export async function changePassword(body: {
+ oldPassword: string;
+ newPassword: string;
+ newPasswordConfirm: string;
+}) {
+ const response = await fetchMutation(
+ "auth/change_password",
+ {
+ method: "POST",
+ body,
+ },
+ );
+ return response;
+}
diff --git a/src/features/password-change/components/change-password-form.tsx b/src/features/password-change/components/change-password-form.tsx
new file mode 100644
index 00000000..5213064b
--- /dev/null
+++ b/src/features/password-change/components/change-password-form.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation } from "@tanstack/react-query";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+
+import { PasswordInput } from "@/components/inputs/password-input";
+import { Button } from "@/components/ui/button";
+import { Form, FormField } from "@/components/ui/form";
+import { FetchError } from "@/features/backend";
+
+import { changePassword } from "../api/change-password";
+import { ChangePasswordSchema } from "../schemas/change-password-schema";
+import type { ChangePasswordFormValues } from "../schemas/change-password-schema";
+
+export function ChangePasswordForm() {
+ const form = useForm({
+ resolver: zodResolver(ChangePasswordSchema),
+ defaultValues: {
+ oldPassword: "",
+ newPassword: "",
+ newPasswordConfirm: "",
+ },
+ });
+
+ const { mutate, isPending } = useMutation({
+ mutationFn: async (data: ChangePasswordFormValues) =>
+ changePassword({
+ oldPassword: data.oldPassword,
+ newPassword: data.newPassword,
+ newPasswordConfirm: data.newPasswordConfirm,
+ }),
+ onSuccess: () => {
+ toast.success("Hasło zmienione poprawnie");
+ form.reset();
+ },
+ onError: (error) => {
+ if (error instanceof FetchError) {
+ const validationIssues = error.errorReport?.error.validationIssues;
+ if (Array.isArray(validationIssues)) {
+ for (const issue of validationIssues) {
+ const fieldName =
+ (issue as Record).field ??
+ (issue as Record).rule;
+ const message = (issue as Record).message;
+ if (fieldName === "oldPassword" && typeof message === "string") {
+ form.setError("oldPassword", {
+ type: "server",
+ message,
+ });
+ toast.error(message);
+ return;
+ }
+ }
+ }
+ toast.error(error.getCodedMessage("Nie udało się zmienić hasła"));
+ } else if (error instanceof Error) {
+ toast.error(error.message);
+ } else {
+ toast.error("Nie udało się zmienić hasła");
+ }
+ },
+ retry: false,
+ });
+
+ return (
+
+
+ );
+}
diff --git a/src/features/password-change/index.ts b/src/features/password-change/index.ts
new file mode 100644
index 00000000..92cef25e
--- /dev/null
+++ b/src/features/password-change/index.ts
@@ -0,0 +1,3 @@
+export * from "./components/change-password-form";
+export * from "./api/change-password";
+export * from "./schemas/change-password-schema";
diff --git a/src/features/password-change/schemas/change-password-schema.ts b/src/features/password-change/schemas/change-password-schema.ts
new file mode 100644
index 00000000..4fac26be
--- /dev/null
+++ b/src/features/password-change/schemas/change-password-schema.ts
@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+import { RequiredStringSchema } from "@/schemas";
+
+export const ChangePasswordSchema = z
+ .object({
+ oldPassword: RequiredStringSchema,
+ newPassword: RequiredStringSchema.min(8, {
+ message: "Hasło musi mieć co najmniej 8 znaków",
+ }),
+ newPasswordConfirm: RequiredStringSchema,
+ })
+ .refine((data) => data.newPassword === data.newPasswordConfirm, {
+ message: "Hasła muszą być identyczne",
+ path: ["newPasswordConfirm"],
+ })
+ .refine((data) => data.oldPassword !== data.newPassword, {
+ message: "Nowe hasło musi się różnić od starego",
+ path: ["newPassword"],
+ });
+
+export type ChangePasswordFormValues = z.infer;