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 ( +
+ { + mutate(data); + })} + className="bg-background w-full max-w-md space-y-4 rounded-xl px-6 py-8" + > + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + +
+ +
+ + + ); +} 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;