Files
freno-dev/src/routes/login/password-reset.tsx
2025-12-23 10:30:51 -05:00

360 lines
11 KiB
TypeScript

import { createSignal, createEffect, Show } from "solid-js";
import { A, useNavigate, useSearchParams } from "@solidjs/router";
import { Title, Meta } from "@solidjs/meta";
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import Eye from "~/components/icons/Eye";
import EyeSlash from "~/components/icons/EyeSlash";
import { validatePassword } from "~/lib/validation";
import { api } from "~/lib/api";
export default function PasswordResetPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
const [passwordChangeLoading, setPasswordChangeLoading] = createSignal(false);
const [passwordsMatch, setPasswordsMatch] = createSignal(false);
const [showPasswordLengthWarning, setShowPasswordLengthWarning] =
createSignal(false);
const [passwordLengthSufficient, setPasswordLengthSufficient] =
createSignal(false);
const [showRequestNewEmail, setShowRequestNewEmail] = createSignal(false);
const [countDown, setCountDown] = createSignal(false);
const [error, setError] = createSignal("");
const [showPasswordInput, setShowPasswordInput] = createSignal(false);
const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false);
let newPasswordRef: HTMLInputElement | undefined;
let newPasswordConfRef: HTMLInputElement | undefined;
// Get token from URL
const token = searchParams.token;
createEffect(() => {
if (!token) {
navigate("/login/request-password-reset");
}
});
const setNewPasswordTrigger = async (e: Event) => {
e.preventDefault();
setShowRequestNewEmail(false);
setError("");
if (!newPasswordRef || !newPasswordConfRef) {
setError("Please fill in all fields");
return;
}
const newPassword = newPasswordRef.value;
const newPasswordConf = newPasswordConfRef.value;
// Validate password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.isValid) {
setError(passwordValidation.errors[0] || "Invalid password");
return;
}
if (newPassword !== newPasswordConf) {
setError("Passwords do not match");
return;
}
setPasswordChangeLoading(true);
try {
const result = await api.auth.resetPassword.mutate({
token: token,
newPassword,
newPasswordConfirmation: newPasswordConf
});
if (result.success) {
setCountDown(true);
} else {
setError("Failed to reset password");
}
} catch (err: any) {
console.error("Password reset error:", err);
const errorMsg = err.message || "An error occurred. Please try again.";
if (errorMsg.includes("expired") || errorMsg.includes("token")) {
setShowRequestNewEmail(true);
setError("Token has expired");
} else {
setError(errorMsg);
}
} finally {
setPasswordChangeLoading(false);
}
};
const checkForMatch = (newPassword: string, newPasswordConf: string) => {
if (newPassword === newPasswordConf) {
setPasswordsMatch(true);
} else {
setPasswordsMatch(false);
}
};
const checkPasswordLength = (password: string) => {
if (password.length >= 8) {
setPasswordLengthSufficient(true);
setShowPasswordLengthWarning(false);
} else {
setPasswordLengthSufficient(false);
if (passwordBlurred()) {
setShowPasswordLengthWarning(true);
}
}
};
const passwordLengthBlurCheck = () => {
if (
!passwordLengthSufficient() &&
newPasswordRef &&
newPasswordRef.value !== ""
) {
setShowPasswordLengthWarning(true);
}
setPasswordBlurred(true);
};
const handleNewPasswordChange = (e: Event) => {
const target = e.target as HTMLInputElement;
checkPasswordLength(target.value);
if (newPasswordConfRef) {
checkForMatch(target.value, newPasswordConfRef.value);
}
};
const handlePasswordConfChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (newPasswordRef) {
checkForMatch(newPasswordRef.value, target.value);
}
};
const handlePasswordBlur = () => {
passwordLengthBlurCheck();
};
// Render countdown timer
const renderTime = (timeRemaining: number) => {
if (timeRemaining === 0) {
navigate("/login");
}
return (
<div class="timer text-center">
<div class="text-green text-sm">Change Successful!</div>
<div class="value text-blue py-1 text-3xl">{timeRemaining}</div>
<div class="text-blue text-sm">Redirecting...</div>
</div>
);
};
return (
<>
<Title>Reset Password | Michael Freno</Title>
<Meta
name="description"
content="Set a new password for your account to regain access to your profile and personalized features."
/>
<div>
<div class="pt-24 text-center text-xl font-semibold">
Set New Password
</div>
<form
onSubmit={(e) => setNewPasswordTrigger(e)}
class="mt-4 flex w-full justify-center"
>
<div class="flex w-full max-w-md flex-col justify-center px-4">
{/* New Password Input */}
<div class="flex justify-center">
<div class="input-group mx-4 flex">
<input
ref={newPasswordRef}
name="newPassword"
type={showPasswordInput() ? "text" : "password"}
required
autofocus
onInput={handleNewPasswordChange}
onBlur={handlePasswordBlur}
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">New Password</label>
</div>
<button
type="button"
onClick={() => {
setShowPasswordInput(!showPasswordInput());
newPasswordRef?.focus();
}}
class="absolute mt-14 ml-60"
>
<Show
when={showPasswordInput()}
fallback={
<EyeSlash
height={24}
width={24}
strokeWidth={1}
class="stroke-text"
/>
}
>
<Eye
height={24}
width={24}
strokeWidth={1}
class="stroke-text"
/>
</Show>
</button>
</div>
{/* Password Length Warning */}
<div
class={`${
showPasswordLengthWarning() ? "" : "opacity-0 select-none"
} text-red text-center transition-opacity duration-200 ease-in-out`}
>
Password too short! Min Length: 8
</div>
{/* Password Confirmation Input */}
<div class="-mt-4 flex justify-center">
<div class="input-group mx-4 flex">
<input
ref={newPasswordConfRef}
name="newPasswordConf"
onInput={handlePasswordConfChange}
type={showPasswordConfInput() ? "text" : "password"}
required
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">
Password Confirmation
</label>
</div>
<button
type="button"
onClick={() => {
setShowPasswordConfInput(!showPasswordConfInput());
newPasswordConfRef?.focus();
}}
class="absolute mt-14 ml-60"
>
<Show
when={showPasswordConfInput()}
fallback={
<EyeSlash
height={24}
width={24}
strokeWidth={1}
class="stroke-text"
/>
}
>
<Eye
height={24}
width={24}
strokeWidth={1}
class="stroke-text"
/>
</Show>
</button>
</div>
{/* Password Mismatch Warning */}
<div
class={`${
!passwordsMatch() &&
passwordLengthSufficient() &&
newPasswordConfRef &&
newPasswordConfRef.value.length >= 6
? ""
: "opacity-0 select-none"
} text-red text-center transition-opacity duration-200 ease-in-out`}
>
Passwords do not match!
</div>
{/* Countdown Timer or Submit Button */}
<Show
when={countDown()}
fallback={
<button
type="submit"
disabled={passwordChangeLoading() || !passwordsMatch()}
class={`${
passwordChangeLoading() || !passwordsMatch()
? "cursor-not-allowed bg-zinc-400"
: "bg-blue hover:brightness-125 active:scale-90"
} my-6 flex justify-center rounded px-4 py-2 text-base font-medium transition-all duration-300 ease-out`}
>
{passwordChangeLoading() ? "Setting..." : "Set New Password"}
</button>
}
>
<div class="mx-auto pt-4">
<CountdownCircleTimer
isPlaying={countDown()}
duration={5}
size={200}
strokeWidth={12}
colors="var(--color-blue)"
onComplete={() => false}
>
{({ remainingTime }) => renderTime(remainingTime)}
</CountdownCircleTimer>
</div>
</Show>
</div>
</form>
{/* Error Message */}
<Show when={error() && !showRequestNewEmail()}>
<div class="mt-4 flex justify-center">
<div class="text-red text-sm italic">{error()}</div>
</div>
</Show>
{/* Token Expired Message */}
<div
class={`${
showRequestNewEmail() ? "" : "opacity-0 select-none"
} text-red flex justify-center px-4 italic transition-opacity duration-300 ease-in-out`}
>
Token has expired, request a new one{" "}
<A
class="text-blue pl-1 underline underline-offset-4 hover:brightness-125"
href="/login/request-password-reset"
>
here
</A>
</div>
{/* Back to Login Link */}
<Show when={!countDown()}>
<div class="mt-6 flex justify-center">
<A
href="/login"
class="text-blue underline underline-offset-4 transition-colors hover:brightness-125"
>
Back to Login
</A>
</div>
</Show>
</div>
</>
);
}