Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app/(private)/change-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ChangePasswordForm } from "@/features/password-change";

export default function ChangePasswordPage() {
return (
<div className="container mx-auto flex h-full flex-col items-center justify-center p-4 sm:p-8">
<h1 className="mb-4 text-2xl font-semibold">Zmiana hasła</h1>
<ChangePasswordForm />
</div>
Comment thread
michalges marked this conversation as resolved.
);
}
5 changes: 2 additions & 3 deletions src/components/inputs/password-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export function PasswordInput({
placeholder?: string;
} & ControllerRenderProps) {
const [showPassword, setShowPassword] = useState(false);
const labelLowerCase = label.toLowerCase();
return (
<FormItem>
<FormLabel>{label}</FormLabel>
Expand All @@ -42,11 +41,11 @@ export function PasswordInput({
</FormControl>
<InputGroupButton
asChild
tooltip={`${showPassword ? "Ukryj" : "Pokaż"} ${labelLowerCase}`}
tooltip={`${showPassword ? "Ukryj" : "Pokaż"} hasło`}
>
<Toggle
size="unset"
aria-label={`Pokaż ${labelLowerCase}`}
aria-label={`${showPassword ? "Ukryj" : "Pokaż"} ${label.toLowerCase()}`}
pressed={showPassword}
onPressedChange={setShowPassword}
Comment thread
michalges marked this conversation as resolved.
>
Expand Down
1 change: 1 addition & 0 deletions src/components/presentation/logout-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function LogoutButton() {
onClick={() => toast.promise(mutateAsync(null), getToastMessages.logout)}
disabled={isPending}
variant="destructive"
className="cursor-pointer"
>
<LogOut className="size-4" />
<span>Wyloguj się</span>
Expand Down
8 changes: 7 additions & 1 deletion src/components/presentation/navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { UserRound } from "lucide-react";
import { KeyRound, UserRound } from "lucide-react";
import { usePathname } from "next/navigation";

import { Link } from "@/components/core/link";
Expand Down Expand Up @@ -61,6 +61,12 @@ function UserProfileMenu({ user }: { user: User | null }) {
{user != null && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/change-password" className="cursor-pointer">
<KeyRound />
Zmiana hasła
</Link>
</DropdownMenuItem>
<LogoutButton />
</>
)}
Expand Down
8 changes: 8 additions & 0 deletions src/data/form-error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,12 @@ export const FORM_ERROR_MESSAGES = {
SELECTION_REQUIRED: "Należy wybrać przynajmniej jedną opcję",
VALUE_TOO_SHORT: (requiredLength: number) =>
`Wpisana wartość jest za krótka. Minimalna długość: ${String(requiredLength)}`,
CHANGE_PASSWORD_MIN_LENGTH: "Hasło musi mieć co najmniej 8 znaków",
CHANGE_PASSWORD_REQUIRE_UPPER:
"Hasło musi zawierać co najmniej jedną wielką literę",
CHANGE_PASSWORD_REQUIRE_LOWER:
"Hasło musi zawierać co najmniej jedną małą literę",
CHANGE_PASSWORD_REQUIRE_NUMBER: "Hasło musi zawierać co najmniej jedną cyfrę",
CHANGE_PASSWORD_PASSWORDS_MUST_MATCH: "Hasła muszą być identyczne",
CHANGE_PASSWORD_MUST_DIFFER: "Nowe hasło musi się różnić od starego",
};
8 changes: 7 additions & 1 deletion src/features/backend/utils/handle-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ export async function handleResponse<T>(
};
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(
Comment thread
Konzum59 marked this conversation as resolved.
{
url: request.url,
method: request.method,
Expand Down
25 changes: 25 additions & 0 deletions src/features/password-change/api/change-password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { fetchMutation } from "@/features/backend";
import type { MessageResponse } from "@/features/backend/types";

export async function changePassword({
oldPassword,
newPassword,
newPasswordConfirm,
}: {
oldPassword: string;
newPassword: string;
newPasswordConfirm: string;
}): Promise<MessageResponse> {
const response = await fetchMutation<MessageResponse>(
"auth/change_password",
{
Comment thread
michalges marked this conversation as resolved.
method: "POST",
body: {
oldPassword,
newPassword,
newPasswordConfirm,
},
},
);
return response;
}
126 changes: 126 additions & 0 deletions src/features/password-change/components/change-password-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"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 { getToastMessages } from "@/lib/get-toast-messages";

import { changePassword } from "../api/change-password";
import { ChangePasswordSchema } from "../schemas/change-password-schema";
import type { ChangePasswordFormValues } from "../schemas/change-password-schema";

/**
* If a fetch request fails due to user input (incorrect old password), displays an appropriate toast message.
* @param {unknown} error - Error thrown by the fetch request.
* @returns {boolean} Whether the error was handled (whether a toast message was displayed).
*/
function handleServerValidationErrors(error: unknown): boolean {
if (!(error instanceof FetchError)) {
return false;
}

const validationIssues = error.errorReport?.error.validationIssues;
if (!Array.isArray(validationIssues)) {
return false;
}
for (const issue of validationIssues) {
const fieldName = issue.field ?? issue.rule;
const refersToOldPassword =
fieldName === "oldPassword" ||
Object.keys(issue).includes("oldPassword") ||
Object.values(issue).includes("oldPassword");
if (refersToOldPassword) {
toast.error(getToastMessages.changePassword.invalidOldPassword);
return true;
}
}
return false;
}

export function ChangePasswordForm() {
const form = useForm<ChangePasswordFormValues>({
resolver: zodResolver(ChangePasswordSchema),
defaultValues: {
oldPassword: "",
newPassword: "",
newPasswordConfirm: "",
},
});

const { mutateAsync, isPending } = useMutation({
mutationFn: changePassword,
});

return (
<Form {...form}>
<form
noValidate
onSubmit={form.handleSubmit(async (data) => {
const messages = getToastMessages.changePassword;
const loadingToast = toast.loading(messages.loading);
try {
await mutateAsync(data);
Comment thread
michalges marked this conversation as resolved.
toast.success(messages.success);
form.reset();
} catch (error: unknown) {
Comment thread
michalges marked this conversation as resolved.
const handled = handleServerValidationErrors(error);
if (!handled) {
toast.error(messages.error);
}
} finally {
toast.dismiss(loadingToast);
}
})}
className="bg-background w-full max-w-md space-y-4 rounded-xl px-6 py-8"
>
<FormField
control={form.control}
name="oldPassword"
render={({ field }) => (
<PasswordInput
label="Aktualne hasło"
placeholder="Aktualne hasło"
{...field}
/>
)}
/>

<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<PasswordInput
label="Nowe hasło"
placeholder="Nowe hasło"
{...field}
/>
)}
/>

<FormField
control={form.control}
name="newPasswordConfirm"
render={({ field }) => (
<PasswordInput
Comment thread
michalges marked this conversation as resolved.
label="Potwierdź nowe hasło"
placeholder="Potwierdź nowe hasło"
{...field}
/>
)}
/>
Comment thread
Konzum59 marked this conversation as resolved.

<div className="flex justify-end">
<Button type="submit" loading={isPending}>
Zmień hasło
</Button>
</div>
</form>
</Form>
);
}
Comment thread
Konzum59 marked this conversation as resolved.
1 change: 1 addition & 0 deletions src/features/password-change/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ChangePasswordForm } from "./components/change-password-form";
32 changes: 32 additions & 0 deletions src/features/password-change/schemas/change-password-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from "zod";

import { FORM_ERROR_MESSAGES } from "@/data/form-error-messages";
import { RequiredStringSchema } from "@/schemas";

export const ChangePasswordSchema = z
.object({
oldPassword: RequiredStringSchema,
newPassword: RequiredStringSchema.min(8, {
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_MIN_LENGTH,
})
.regex(/[A-Z]/, {
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_REQUIRE_UPPER,
})
.regex(/[a-z]/, {
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_REQUIRE_LOWER,
})
.regex(/[0-9]/, {
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_REQUIRE_NUMBER,
}),
newPasswordConfirm: RequiredStringSchema,
Comment thread
michalges marked this conversation as resolved.
})
.refine((data) => data.newPassword === data.newPasswordConfirm, {
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_PASSWORDS_MUST_MATCH,
path: ["newPasswordConfirm"],
})
.refine((data) => data.oldPassword !== data.newPassword, {
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_MUST_DIFFER,
path: ["newPassword"],
});

export type ChangePasswordFormValues = z.infer<typeof ChangePasswordSchema>;
6 changes: 6 additions & 0 deletions src/lib/get-toast-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,10 @@ export const getToastMessages = {
auth: {
invalidCookie: "Proszę zalogować się ponownie.",
},
changePassword: {
loading: "Trwa zmiana hasła...",
success: "Hasło zmienione poprawnie",
error: "Nie udało się zmienić hasła",
invalidOldPassword: "Podane aktualne hasło jest niepoprawne",
},
};
Loading