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:
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user