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:
2026-06-30 15:49:23 -04:00
parent f4437c5e1d
commit 82ddf914e4
6 changed files with 276 additions and 1 deletions
+4
View File
@@ -9,6 +9,8 @@ import { CommandManager } from './components/CommandManager.tsx';
import { RoleManager } from './components/RoleManager.tsx';
import { JoinServer } from './components/JoinServer.tsx';
import { DMChat } from './components/DMChat.tsx';
import { ForgotPasswordPage } from './components/ForgotPasswordPage.tsx';
import { ResetPasswordPage } from './components/ResetPasswordPage.tsx';
import { useAuthStore } from './stores/auth.ts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -32,6 +34,8 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route
path="/settings"
element={
+88
View File
@@ -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>
);
}
+10
View File
@@ -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
+137
View File
@@ -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>
);
}
+36
View File
@@ -62,6 +62,8 @@ interface AuthState {
currentPassword: string,
newPassword: string,
) => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, newPassword: string) => Promise<void>;
getPublicProfile: (userId: string) => Promise<PublicProfile>;
listBlocks: () => Promise<BlockedUser[]>;
blockUser: (userId: string) => Promise<void>;
@@ -178,6 +180,40 @@ export const useAuthStore = create<AuthState>((set) => ({
}
},
requestPasswordReset: async (email) => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/request-password-reset", { email });
set({ isLoading: false });
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: "Failed to request password reset",
});
throw error;
}
},
resetPassword: async (token, newPassword) => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/reset-password", { token, new_password: newPassword });
set({ isLoading: false });
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: "Failed to reset password",
});
throw error;
}
},
clearError: () => set({ error: null }),
getPublicProfile: async (userId) => api.get<PublicProfile>(`/users/${userId}/profile`),