Skip to content

Commit 48859c4

Browse files
Konzum59michalges
andauthored
feat(auth): password change (#226)
* feat: add option to change password * feat: add proper validation * fix: small changes * feat: add adjustments from the comments * fix: small review changes * refactor: simplify password change error logic * refactor: simplify handleServerValidationErrors types * fix: remove unnecessary styling --------- Co-authored-by: michalges <michal.ges.mail@gmail.com>
1 parent bc5eefd commit 48859c4

11 files changed

Lines changed: 225 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ChangePasswordForm } from "@/features/password-change";
2+
3+
export default function ChangePasswordPage() {
4+
return (
5+
<div className="container mx-auto flex h-full flex-col items-center justify-center p-4 sm:p-8">
6+
<h1 className="mb-4 text-2xl font-semibold">Zmiana hasła</h1>
7+
<ChangePasswordForm />
8+
</div>
9+
);
10+
}

src/components/inputs/password-input.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export function PasswordInput({
2828
placeholder?: string;
2929
} & ControllerRenderProps) {
3030
const [showPassword, setShowPassword] = useState(false);
31-
const labelLowerCase = label.toLowerCase();
3231
return (
3332
<FormItem>
3433
<FormLabel>{label}</FormLabel>
@@ -42,11 +41,11 @@ export function PasswordInput({
4241
</FormControl>
4342
<InputGroupButton
4443
asChild
45-
tooltip={`${showPassword ? "Ukryj" : "Pokaż"} ${labelLowerCase}`}
44+
tooltip={`${showPassword ? "Ukryj" : "Pokaż"} hasło`}
4645
>
4746
<Toggle
4847
size="unset"
49-
aria-label={`Pokaż ${labelLowerCase}`}
48+
aria-label={`${showPassword ? "Ukryj" : "Pokaż"} ${label.toLowerCase()}`}
5049
pressed={showPassword}
5150
onPressedChange={setShowPassword}
5251
>

src/components/presentation/logout-button.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function LogoutButton() {
4242
onClick={() => toast.promise(mutateAsync(null), getToastMessages.logout)}
4343
disabled={isPending}
4444
variant="destructive"
45+
className="cursor-pointer"
4546
>
4647
<LogOut className="size-4" />
4748
<span>Wyloguj się</span>

src/components/presentation/navbar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

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

66
import { Link } from "@/components/core/link";
@@ -61,6 +61,12 @@ function UserProfileMenu({ user }: { user: User | null }) {
6161
{user != null && (
6262
<>
6363
<DropdownMenuSeparator />
64+
<DropdownMenuItem asChild>
65+
<Link href="/change-password" className="cursor-pointer">
66+
<KeyRound />
67+
Zmiana hasła
68+
</Link>
69+
</DropdownMenuItem>
6470
<LogoutButton />
6571
</>
6672
)}

src/data/form-error-messages.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,12 @@ export const FORM_ERROR_MESSAGES = {
1515
SELECTION_REQUIRED: "Należy wybrać przynajmniej jedną opcję",
1616
VALUE_TOO_SHORT: (requiredLength: number) =>
1717
`Wpisana wartość jest za krótka. Minimalna długość: ${String(requiredLength)}`,
18+
CHANGE_PASSWORD_MIN_LENGTH: "Hasło musi mieć co najmniej 8 znaków",
19+
CHANGE_PASSWORD_REQUIRE_UPPER:
20+
"Hasło musi zawierać co najmniej jedną wielką literę",
21+
CHANGE_PASSWORD_REQUIRE_LOWER:
22+
"Hasło musi zawierać co najmniej jedną małą literę",
23+
CHANGE_PASSWORD_REQUIRE_NUMBER: "Hasło musi zawierać co najmniej jedną cyfrę",
24+
CHANGE_PASSWORD_PASSWORDS_MUST_MATCH: "Hasła muszą być identyczne",
25+
CHANGE_PASSWORD_MUST_DIFFER: "Nowe hasło musi się różnić od starego",
1826
};

src/features/backend/utils/handle-response.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ export async function handleResponse<T>(
3030
};
3131
const code = errorReport?.error.code ?? String(response.status);
3232
const errorMessage = `Request failed with code ${code}: ${message}`;
33-
logger.error(
33+
34+
const isValidationError =
35+
code === "E_VALIDATION_ERROR" ||
36+
Array.isArray(errorReport?.error.validationIssues);
37+
const logFunction = isValidationError ? logger.warn : logger.error;
38+
39+
logFunction(
3440
{
3541
url: request.url,
3642
method: request.method,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { fetchMutation } from "@/features/backend";
2+
import type { MessageResponse } from "@/features/backend/types";
3+
4+
export async function changePassword({
5+
oldPassword,
6+
newPassword,
7+
newPasswordConfirm,
8+
}: {
9+
oldPassword: string;
10+
newPassword: string;
11+
newPasswordConfirm: string;
12+
}): Promise<MessageResponse> {
13+
const response = await fetchMutation<MessageResponse>(
14+
"auth/change_password",
15+
{
16+
method: "POST",
17+
body: {
18+
oldPassword,
19+
newPassword,
20+
newPasswordConfirm,
21+
},
22+
},
23+
);
24+
return response;
25+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import { zodResolver } from "@hookform/resolvers/zod";
4+
import { useMutation } from "@tanstack/react-query";
5+
import { useForm } from "react-hook-form";
6+
import { toast } from "sonner";
7+
8+
import { PasswordInput } from "@/components/inputs/password-input";
9+
import { Button } from "@/components/ui/button";
10+
import { Form, FormField } from "@/components/ui/form";
11+
import { FetchError } from "@/features/backend";
12+
import { getToastMessages } from "@/lib/get-toast-messages";
13+
14+
import { changePassword } from "../api/change-password";
15+
import { ChangePasswordSchema } from "../schemas/change-password-schema";
16+
import type { ChangePasswordFormValues } from "../schemas/change-password-schema";
17+
18+
/**
19+
* If a fetch request fails due to user input (incorrect old password), displays an appropriate toast message.
20+
* @param {unknown} error - Error thrown by the fetch request.
21+
* @returns {boolean} Whether the error was handled (whether a toast message was displayed).
22+
*/
23+
function handleServerValidationErrors(error: unknown): boolean {
24+
if (!(error instanceof FetchError)) {
25+
return false;
26+
}
27+
28+
const validationIssues = error.errorReport?.error.validationIssues;
29+
if (!Array.isArray(validationIssues)) {
30+
return false;
31+
}
32+
for (const issue of validationIssues) {
33+
const fieldName = issue.field ?? issue.rule;
34+
const refersToOldPassword =
35+
fieldName === "oldPassword" ||
36+
Object.keys(issue).includes("oldPassword") ||
37+
Object.values(issue).includes("oldPassword");
38+
if (refersToOldPassword) {
39+
toast.error(getToastMessages.changePassword.invalidOldPassword);
40+
return true;
41+
}
42+
}
43+
return false;
44+
}
45+
46+
export function ChangePasswordForm() {
47+
const form = useForm<ChangePasswordFormValues>({
48+
resolver: zodResolver(ChangePasswordSchema),
49+
defaultValues: {
50+
oldPassword: "",
51+
newPassword: "",
52+
newPasswordConfirm: "",
53+
},
54+
});
55+
56+
const { mutateAsync, isPending } = useMutation({
57+
mutationFn: changePassword,
58+
});
59+
60+
return (
61+
<Form {...form}>
62+
<form
63+
noValidate
64+
onSubmit={form.handleSubmit(async (data) => {
65+
const messages = getToastMessages.changePassword;
66+
const loadingToast = toast.loading(messages.loading);
67+
try {
68+
await mutateAsync(data);
69+
toast.success(messages.success);
70+
form.reset();
71+
} catch (error: unknown) {
72+
const handled = handleServerValidationErrors(error);
73+
if (!handled) {
74+
toast.error(messages.error);
75+
}
76+
} finally {
77+
toast.dismiss(loadingToast);
78+
}
79+
})}
80+
className="bg-background w-full max-w-md space-y-4 rounded-xl px-6 py-8"
81+
>
82+
<FormField
83+
control={form.control}
84+
name="oldPassword"
85+
render={({ field }) => (
86+
<PasswordInput
87+
label="Aktualne hasło"
88+
placeholder="Aktualne hasło"
89+
{...field}
90+
/>
91+
)}
92+
/>
93+
94+
<FormField
95+
control={form.control}
96+
name="newPassword"
97+
render={({ field }) => (
98+
<PasswordInput
99+
label="Nowe hasło"
100+
placeholder="Nowe hasło"
101+
{...field}
102+
/>
103+
)}
104+
/>
105+
106+
<FormField
107+
control={form.control}
108+
name="newPasswordConfirm"
109+
render={({ field }) => (
110+
<PasswordInput
111+
label="Potwierdź nowe hasło"
112+
placeholder="Potwierdź nowe hasło"
113+
{...field}
114+
/>
115+
)}
116+
/>
117+
118+
<div className="flex justify-end">
119+
<Button type="submit" loading={isPending}>
120+
Zmień hasło
121+
</Button>
122+
</div>
123+
</form>
124+
</Form>
125+
);
126+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChangePasswordForm } from "./components/change-password-form";
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from "zod";
2+
3+
import { FORM_ERROR_MESSAGES } from "@/data/form-error-messages";
4+
import { RequiredStringSchema } from "@/schemas";
5+
6+
export const ChangePasswordSchema = z
7+
.object({
8+
oldPassword: RequiredStringSchema,
9+
newPassword: RequiredStringSchema.min(8, {
10+
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_MIN_LENGTH,
11+
})
12+
.regex(/[A-Z]/, {
13+
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_REQUIRE_UPPER,
14+
})
15+
.regex(/[a-z]/, {
16+
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_REQUIRE_LOWER,
17+
})
18+
.regex(/[0-9]/, {
19+
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_REQUIRE_NUMBER,
20+
}),
21+
newPasswordConfirm: RequiredStringSchema,
22+
})
23+
.refine((data) => data.newPassword === data.newPasswordConfirm, {
24+
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_PASSWORDS_MUST_MATCH,
25+
path: ["newPasswordConfirm"],
26+
})
27+
.refine((data) => data.oldPassword !== data.newPassword, {
28+
message: FORM_ERROR_MESSAGES.CHANGE_PASSWORD_MUST_DIFFER,
29+
path: ["newPassword"],
30+
});
31+
32+
export type ChangePasswordFormValues = z.infer<typeof ChangePasswordSchema>;

0 commit comments

Comments
 (0)