Add forgot password feature

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-06-20 10:32:33 -07:00
parent 3b7d246e3f
commit de0ea0cc9f
7 changed files with 296 additions and 3 deletions

View File

@@ -109,7 +109,7 @@ export default function ConfirmInvitationPage() {
<p className="text-sm text-txt-tertiary">
{__("Already have an account?")}{" "}
<Link
to="/login"
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Log in here")}

View File

@@ -0,0 +1,136 @@
import { Link } from "react-router";
import { Button, Field, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
import { useState } from "react";
const schema = z.object({
email: z.string().email(),
});
export default function ForgotPasswordPage() {
const { __ } = useTranslate();
const { toast } = useToast();
const [isSubmitted, setIsSubmitted] = useState(false);
const { register, handleSubmit, formState } = useFormWithSchema(schema, {
defaultValues: {
email: "",
},
});
const onSubmit = handleSubmit(async (data) => {
const response = await fetch(
buildEndpoint("/api/console/v1/auth/forget-password"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
toast({
title: __("Request failed"),
description:
errorData.message || __("Failed to send reset instructions"),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Password reset instructions sent to your email"),
variant: "success",
});
setIsSubmitted(true);
});
usePageTitle(__("Forgot Password"));
if (isSubmitted) {
return (
<div className="space-y-6 w-full max-w-md mx-auto">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{__("Check your email")}</h1>
<p className="text-txt-tertiary">
{__("We've sent password reset instructions to your email address")}
</p>
</div>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Didn't receive the email?")}{" "}
<button
onClick={() => setIsSubmitted(false)}
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Try again")}
</button>
</p>
</div>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Remember your password?")}{" "}
<Link
to="/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Back to login")}
</Link>
</p>
</div>
</div>
);
}
return (
<div className="space-y-6 w-full max-w-md mx-auto">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{__("Forgot Password")}</h1>
<p className="text-txt-tertiary">
{__(
"Enter your email address and we'll send you instructions to reset your password"
)}
</p>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<Field
label={__("Email")}
type="email"
placeholder={__("name@example.com")}
{...register("email")}
required
error={formState.errors.email?.message}
/>
<Button type="submit" className="w-full" disabled={formState.isLoading}>
{formState.isLoading
? __("Sending instructions...")
: __("Send reset instructions")}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Remember your password?")}{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Back to login")}
</Link>
</p>
</div>
</div>
);
}

View File

@@ -67,6 +67,15 @@ export default function LoginPage() {
{__("Register")}
</Link>
</div>
<div className="text-center mt-6 text-sm text-txt-secondary">
{__("Forgot password?")}{" "}
<Link
to="/auth/forgot-password"
className="underline hover:text-txt-primary"
>
{__("Reset password")}
</Link>
</div>
</form>
);
}

View File

@@ -106,7 +106,7 @@ export default function RegisterPage() {
<p className="text-sm text-txt-tertiary">
{__("Already have an account?")}{" "}
<Link
to="/login"
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Log in here")}

View File

@@ -0,0 +1,140 @@
import { Link, useNavigate } from "react-router";
import { Button, Field, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
import { useEffect } from "react";
const schema = z
.object({
token: z.string(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export default function ResetPasswordPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast();
const { register, handleSubmit, formState, setValue } = useFormWithSchema(
schema,
{
defaultValues: {
token: "",
password: "",
confirmPassword: "",
},
}
);
const onSubmit = handleSubmit(async (data) => {
const response = await fetch(
buildEndpoint("/api/console/v1/auth/reset-password"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
token: data.token,
password: data.password,
}),
}
);
// Reset failed
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
toast({
title: __("Reset failed"),
description: errorData.message || __("Password reset failed"),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Password reset successfully"),
variant: "success",
});
navigate("/auth/login", { replace: true });
});
usePageTitle(__("Reset password"));
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const urlToken = searchParams.get("token");
if (urlToken) {
setValue("token", urlToken);
}
}, [location.search, setValue]);
return (
<div className="space-y-6 w-full max-w-md mx-auto">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{__("Reset password")}</h1>
<p className="text-txt-tertiary">
{__("Enter your new password to reset your account")}
</p>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<Field
label={__("Token")}
type="text"
hidden
placeholder={__("Enter your token")}
{...register("token")}
required
error={formState.errors.token?.message}
/>
<Field
label={__("New Password")}
type="password"
placeholder="••••••••"
{...register("password")}
required
error={formState.errors.password?.message}
/>
<Field
label={__("Confirm Password")}
type="password"
placeholder="••••••••"
{...register("confirmPassword")}
required
error={formState.errors.confirmPassword?.message}
/>
<Button type="submit" className="w-full" disabled={formState.isLoading}>
{formState.isLoading
? __("Resetting password...")
: __("Reset password")}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{__("Remember your password?")}{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{__("Log in here")}
</Link>
</p>
</div>
</div>
);
}

View File

@@ -70,6 +70,14 @@ const routes = [
path: "confirm-invitation",
Component: lazy(() => import("./pages/auth/ConfirmInvitationPage")),
},
{
path: "forgot-password",
Component: lazy(() => import("./pages/auth/ForgotPasswordPage")),
},
{
path: "reset-password",
Component: lazy(() => import("./pages/auth/ResetPasswordPage")),
},
],
},
{