Skip to content
Open
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
46 changes: 8 additions & 38 deletions src/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,58 +7,26 @@ import { useRouter } from "next/navigation";

import LinkText from "@/components/link-text";
import PasswordCriteria from "@/features/auth/components/password-criteria";
import PasswordValidation from "@/features/auth/components/password-validation";
import TextInputField from "@/features/auth/components/text-input-field";
import ActionButton from "@/features/button/components/action";
import { useDebounce } from "@/lib/hooks/use-debounce";
import formatApiError from "@/lib/utils/api/format-api-error";

export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordCriteria, setPasswordCriteria] = useState({});
const [showPasswordCriteria, setShowPasswordCriteria] = useState(false);
const router = useRouter();

function passwordIsStrong() {
return Object.keys(passwordCriteria).length === 0;
return Object.values(passwordCriteria).every((value) => value === true);
}

useDebounce(() => {
if (password.length === 0) {
setPasswordCriteria({});
return;
}

// Check that the password is strong enough with the API
fetch("/api/auth/check-password/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
})
.then((res) => {
if (res.ok) {
res.json().then((data) => {
if (data.is_strong) {
setPasswordCriteria({});
return;
} else {
setPasswordCriteria(data.criteria || {});
}
});
} else {
console.error("Fetch error:", res.status);
}
})
.catch((err) => {
console.error("Fetch error:", err);
});
}, [password]);

useEffect(() => {
if (password.length === 0) {
setPasswordCriteria({});
return;
}
const { criteria } = PasswordValidation(password);
setPasswordCriteria(criteria);
}, [password]);

const stopRefresh = (e: React.FormEvent) => {
Expand Down Expand Up @@ -127,10 +95,12 @@ export default function Page() {
placeholder="Password"
value={password}
onChange={setPassword}
onFocus={() => setShowPasswordCriteria(true)}
onBlur={() => setShowPasswordCriteria(false)}
/>

{/* Password Errors */}
{!passwordIsStrong() && (
{showPasswordCriteria && (
<div className="-mt-2 mb-2 w-full px-4">
<PasswordCriteria criteria={passwordCriteria} />
</div>
Expand Down
47 changes: 9 additions & 38 deletions src/app/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,28 @@ import React, { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";

import PasswordCriteria from "@/features/auth/components/password-criteria";
import PasswordValidation from "@/features/auth/components/password-validation";
import TextInputField from "@/features/auth/components/text-input-field";
import ActionButton from "@/features/button/components/action";
import { useDebounce } from "@/lib/hooks/use-debounce";
import formatApiError from "@/lib/utils/api/format-api-error";

export default function Page() {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordCriteria, setPasswordCriteria] = useState({});
const [showPasswordCriteria, setShowPasswordCriteria] = useState(false);
const router = useRouter();

const searchParams = useSearchParams();
const pwdResetToken = searchParams.get("token");

function passwordIsStrong() {
return Object.keys(passwordCriteria).length === 0;
return Object.values(passwordCriteria).every((value) => value === true);
}

useDebounce(() => {
if (newPassword.length === 0) {
setPasswordCriteria({});
return;
}

// Check that the password is strong enough with the API
fetch("/api/auth/check-password/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword }),
})
.then((res) => {
if (res.ok) {
res.json().then((data) => {
if (data.is_strong) {
setPasswordCriteria({});
return;
} else {
setPasswordCriteria(data.criteria || {});
}
});
} else {
console.error("Fetch error:", res.status);
}
})
.catch((err) => {
console.error("Fetch error:", err);
});
}, [newPassword]);

useEffect(() => {
if (newPassword.length === 0) {
setPasswordCriteria({});
return;
}
const { criteria } = PasswordValidation(newPassword);
setPasswordCriteria(criteria);
}, [newPassword]);

const stopRefresh = (e: React.FormEvent) => {
Expand Down Expand Up @@ -121,9 +89,12 @@ export default function Page() {
placeholder="New Password"
value={newPassword}
onChange={setNewPassword}
onFocus={() => setShowPasswordCriteria(true)}
onBlur={() => setShowPasswordCriteria(false)}
/>

{!passwordIsStrong() && (
{/* Password Errors */}
{showPasswordCriteria && (
<div className="-mt-2 mb-2 w-full px-4">
<PasswordCriteria criteria={passwordCriteria} />
</div>
Expand Down
35 changes: 23 additions & 12 deletions src/features/auth/components/password-criteria.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,32 @@ type PasswordCriteriaProps = {
};

export default function PasswordCriteria(props: PasswordCriteriaProps) {
const allCriteriaMet = Object.values(props.criteria).every((value) => value);

return (
<div className="w-full text-sm">
<b>Your password must:</b>
{Object.entries(props.criteria).map(([key, value], index) => (
<div
key={index}
className={cn(
"flex items-center gap-1",
value ? "line-through opacity-50" : "",
)}
>
{value ? <CheckIcon /> : <Cross2Icon />}
{key}
{allCriteriaMet ? (
<div className="flex items-center gap-1">
<CheckIcon />
<b>Password is strong!</b>
</div>
))}
) : (
<>
<b>Your password must:</b>
{Object.entries(props.criteria).map(([key, value], index) => (
<div
key={index}
className={cn(
"flex items-center gap-1",
value ? "line-through opacity-50" : "",
)}
>
{value ? <CheckIcon /> : <Cross2Icon />}
{key}
</div>
))}
</>
)}
</div>
);
}
33 changes: 33 additions & 0 deletions src/features/auth/components/password-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const MIN_LENGTH = 8;
const SPECIAL_CHARACTERS = `!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`;

export const PASSWORD_CRITERIA = {
LENGTH: `be at least ${MIN_LENGTH} characters long`,
LOWER: "contain at least one lowercase letter",
UPPER: "contain at least one uppercase letter",
DIGIT: "contain at least one digit",
SPECIAL: "contain at least one special character",
} as const;

export type PasswordCriteriaResult = {
[key: string]: boolean;
};

export default function PasswordValidation(password: string): {
isStrong: boolean;
criteria: PasswordCriteriaResult;
} {
const criteria: PasswordCriteriaResult = {
[PASSWORD_CRITERIA.LENGTH]: password.length >= MIN_LENGTH,
[PASSWORD_CRITERIA.LOWER]: /[a-z]/.test(password),
[PASSWORD_CRITERIA.UPPER]: /[A-Z]/.test(password),
[PASSWORD_CRITERIA.DIGIT]: /\d/.test(password),
[PASSWORD_CRITERIA.SPECIAL]: new RegExp(
`[${SPECIAL_CHARACTERS.replace(/[\\^\-\]]/g, "\\$&")}]`,
).test(password),
};

const isStrong = Object.values(criteria).every((passed) => passed);

return { isStrong, criteria };
}
6 changes: 5 additions & 1 deletion src/features/auth/components/text-input-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ type TextInputFieldProps = {
placeholder: string;
value: string;
onChange: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
};

export default function TextInputField(props: TextInputFieldProps) {
const { type, placeholder, value, onChange } = props;
const { type, placeholder, value, onChange, onFocus, onBlur } = props;
const [showPassword, setShowPassword] = useState(false);

return (
Expand All @@ -22,6 +24,8 @@ export default function TextInputField(props: TextInputFieldProps) {
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
className={
"w-full rounded-full border px-4 py-2 focus:outline-none focus:ring-2" +
(type === "password" ? " pr-10" : "")
Expand Down
Loading