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 { RoleManager } from './components/RoleManager.tsx';
|
||||||
import { JoinServer } from './components/JoinServer.tsx';
|
import { JoinServer } from './components/JoinServer.tsx';
|
||||||
import { DMChat } from './components/DMChat.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';
|
import { useAuthStore } from './stores/auth.ts';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -32,6 +34,8 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginForm />} />
|
<Route path="/login" element={<LoginForm />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
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]"}
|
: "[LOGIN]"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 && (
|
{!isRegister && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<button
|
<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,
|
currentPassword: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
requestPasswordReset: (email: string) => Promise<void>;
|
||||||
|
resetPassword: (token: string, newPassword: string) => Promise<void>;
|
||||||
getPublicProfile: (userId: string) => Promise<PublicProfile>;
|
getPublicProfile: (userId: string) => Promise<PublicProfile>;
|
||||||
listBlocks: () => Promise<BlockedUser[]>;
|
listBlocks: () => Promise<BlockedUser[]>;
|
||||||
blockUser: (userId: string) => Promise<void>;
|
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 }),
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
getPublicProfile: async (userId) => api.get<PublicProfile>(`/users/${userId}/profile`),
|
getPublicProfile: async (userId) => api.get<PublicProfile>(`/users/${userId}/profile`),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user