feat: add forgot password UI flow
- ForgotPasswordPage: email input, sends reset request, shows success - ResetPasswordPage: reads token from ?token= query param, password + confirm inputs, validates min length, shows success with login link - LoginForm: added [FORGOT PASSWORD?] link below the form - App.tsx: /forgot-password and /reset-password routes (public, no auth) - auth store: requestPasswordReset + resetPassword actions - Backend routes already existed at POST /auth/request-password-reset and POST /auth/reset-password
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const { requestPasswordReset, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
try {
|
||||
await requestPasswordReset(email);
|
||||
setSent(true);
|
||||
} catch {
|
||||
// error displayed from store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg p-4">
|
||||
<div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md">
|
||||
<pre className="text-gb-orange font-mono text-lg mb-6">
|
||||
{"┌─ DUMPSTER ─┐"}
|
||||
</pre>
|
||||
<h2 className="text-gb-fg-s mb-4">[FORGOT PASSWORD]</h2>
|
||||
|
||||
{sent ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gb-fg text-sm">
|
||||
If an account exists for{" "}
|
||||
<span className="text-gb-aqua">{email}</span>, we've sent a
|
||||
password reset link. Check your inbox.
|
||||
</p>
|
||||
<p className="text-gb-fg-f text-xs">
|
||||
The link expires in 1 hour.
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gb-blue hover:text-gb-aqua text-sm"
|
||||
>
|
||||
[BACK TO LOGIN]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-gb-fg-t text-sm">
|
||||
Enter the email address associated with your account and we'll
|
||||
send a link to reset your password.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-gb-fg-t mb-1">EMAIL:</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="terminal-input"
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-gb-red text-sm">ERR: {error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="terminal-button w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "[SENDING...]" : "[SEND RESET LINK]"}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gb-blue hover:text-gb-aqua text-sm"
|
||||
>
|
||||
[BACK TO LOGIN]
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,6 +116,16 @@ export function LoginForm() {
|
||||
: "[LOGIN]"}
|
||||
</button>
|
||||
</form>
|
||||
{!isRegister && (
|
||||
<div className="mt-3 text-center">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-gb-yellow hover:text-gb-orange text-xs"
|
||||
>
|
||||
[FORGOT PASSWORD?]
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isRegister && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token") || "";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const { resetPassword, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setLocalError(null);
|
||||
|
||||
if (password.length < 8) {
|
||||
setLocalError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPassword(token, password);
|
||||
setSuccess(true);
|
||||
} catch {
|
||||
// error displayed from store
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg p-4">
|
||||
<div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md">
|
||||
<pre className="text-gb-orange font-mono text-lg mb-6">
|
||||
{"┌─ DUMPSTER ─┐"}
|
||||
</pre>
|
||||
<h2 className="text-gb-red mb-4">[INVALID LINK]</h2>
|
||||
<p className="text-gb-fg text-sm mb-4">
|
||||
This password reset link is missing or invalid. Request a new one.
|
||||
</p>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-gb-blue hover:text-gb-aqua text-sm"
|
||||
>
|
||||
[REQUEST NEW LINK]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg p-4">
|
||||
<div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md">
|
||||
<pre className="text-gb-orange font-mono text-lg mb-6">
|
||||
{"┌─ DUMPSTER ─┐"}
|
||||
</pre>
|
||||
<h2 className="text-gb-fg-s mb-4">[RESET PASSWORD]</h2>
|
||||
|
||||
{success ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gb-green text-sm">
|
||||
Password updated successfully.
|
||||
</p>
|
||||
<p className="text-gb-fg-f text-xs">
|
||||
All existing sessions have been logged out.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
className="terminal-button w-full"
|
||||
>
|
||||
[GO TO LOGIN]
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gb-fg-t mb-1">NEW PASSWORD:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="terminal-input"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gb-fg-t mb-1">
|
||||
CONFIRM PASSWORD:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="terminal-input"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{(localError || error) && (
|
||||
<p className="text-gb-red text-sm">
|
||||
ERR: {localError || error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="terminal-button w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "[UPDATING...]" : "[RESET PASSWORD]"}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gb-blue hover:text-gb-aqua text-sm"
|
||||
>
|
||||
[BACK TO LOGIN]
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user