feat: password reset UI (forgot password, reset page, admin reset button)
This commit is contained in:
@@ -11,6 +11,8 @@ import EmailsPage from '@/pages/EmailsPage';
|
|||||||
import SettingsPage from '@/pages/SettingsPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
import AdminPage from '@/pages/AdminPage';
|
import AdminPage from '@/pages/AdminPage';
|
||||||
import InvitePage from '@/pages/InvitePage';
|
import InvitePage from '@/pages/InvitePage';
|
||||||
|
import ForgotPasswordPage from '@/pages/ForgotPasswordPage';
|
||||||
|
import ResetPasswordPage from '@/pages/ResetPasswordPage';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -34,6 +36,8 @@ export default function App() {
|
|||||||
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||||
} />
|
} />
|
||||||
<Route path="/invite/:token" element={<InvitePage />} />
|
<Route path="/invite/:token" element={<InvitePage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout />
|
<Layout />
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ class ApiClient {
|
|||||||
await this.fetch(`/admin/invites/${id}`, { method: 'DELETE' });
|
await this.fetch(`/admin/invites/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createPasswordReset(userId: string): Promise<{ resetUrl: string; email: string }> {
|
||||||
|
return this.fetch(`/admin/users/${userId}/reset-password`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
// Invite acceptance (public - no auth)
|
// Invite acceptance (public - no auth)
|
||||||
async validateInvite(token: string): Promise<{ id: string; email: string; name: string; role: string; expiresAt: string }> {
|
async validateInvite(token: string): Promise<{ id: string; email: string; name: string; role: string; expiresAt: string }> {
|
||||||
const response = await fetch(`${AUTH_BASE}/auth/invite/${token}`);
|
const response = await fetch(`${AUTH_BASE}/auth/invite/${token}`);
|
||||||
@@ -265,6 +269,41 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestPasswordReset(email: string): Promise<{ success: boolean; message: string; resetUrl?: string }> {
|
||||||
|
const response = await fetch(`${AUTH_BASE}/auth/reset-password/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
|
throw new Error(error.error || 'Request failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateResetToken(token: string): Promise<{ valid: boolean; email?: string }> {
|
||||||
|
const response = await fetch(`${AUTH_BASE}/auth/reset-password/${token}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Invalid token' }));
|
||||||
|
throw new Error(error.error || 'Invalid or expired reset link');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(token: string, password: string): Promise<{ success: boolean }> {
|
||||||
|
const response = await fetch(`${AUTH_BASE}/auth/reset-password/${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Reset failed' }));
|
||||||
|
throw new Error(error.error || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
async acceptInvite(token: string, password: string, name?: string): Promise<{ success: boolean; token?: string }> {
|
async acceptInvite(token: string, password: string, name?: string): Promise<{ success: boolean; token?: string }> {
|
||||||
const response = await fetch(`${AUTH_BASE}/auth/invite/${token}/accept`, {
|
const response = await fetch(`${AUTH_BASE}/auth/invite/${token}/accept`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Users, Mail, Plus, Trash2, Copy, Check } from 'lucide-react';
|
import { Users, Mail, Plus, Trash2, Copy, Check, KeyRound } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { User, Invite } from '@/types';
|
import type { User, Invite } from '@/types';
|
||||||
@@ -21,6 +21,12 @@ export default function AdminPage() {
|
|||||||
const [inviteUrl, setInviteUrl] = useState('');
|
const [inviteUrl, setInviteUrl] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Password reset
|
||||||
|
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
|
const [resetUrl, setResetUrl] = useState('');
|
||||||
|
const [resetCopied, setResetCopied] = useState(false);
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -69,6 +75,31 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateResetLink = async (userId: string) => {
|
||||||
|
setResetLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.createPasswordReset(userId);
|
||||||
|
setResetUserId(userId);
|
||||||
|
setResetUrl(result.resetUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate reset link:', error);
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyResetUrl = () => {
|
||||||
|
navigator.clipboard.writeText(resetUrl);
|
||||||
|
setResetCopied(true);
|
||||||
|
setTimeout(() => setResetCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissReset = () => {
|
||||||
|
setResetUserId(null);
|
||||||
|
setResetUrl('');
|
||||||
|
setResetCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteUser = async (userId: string) => {
|
const handleDeleteUser = async (userId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) return;
|
if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) return;
|
||||||
try {
|
try {
|
||||||
@@ -180,13 +211,44 @@ export default function AdminPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{user.id !== currentUser?.id && (
|
{user.id !== currentUser?.id && (
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => handleDeleteUser(user.id)}
|
{resetUserId === user.id ? (
|
||||||
className="p-1 text-slate-400 hover:text-red-500 transition-colors"
|
<div className="flex items-center gap-1">
|
||||||
title="Delete user"
|
<span className="text-xs text-green-600 whitespace-nowrap">✓ Link ready</span>
|
||||||
>
|
<button
|
||||||
<Trash2 className="w-4 h-4" />
|
onClick={handleCopyResetUrl}
|
||||||
</button>
|
className="text-xs px-2 py-1 bg-blue-600 text-white rounded flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{resetCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{resetCopied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={dismissReset}
|
||||||
|
className="text-xs px-1 py-1 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerateResetLink(user.id)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
className="p-1 text-slate-400 hover:text-blue-500 transition-colors"
|
||||||
|
title="Generate password reset link"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user.id)}
|
||||||
|
className="p-1 text-slate-400 hover:text-red-500 transition-colors"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
105
src/pages/ForgotPasswordPage.tsx
Normal file
105
src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Network, ArrowLeft } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.requestPasswordReset(email);
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to request password reset');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
|
||||||
|
<Network className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Forgot Password</h1>
|
||||||
|
<p className="text-slate-500 mt-1">
|
||||||
|
{submitted
|
||||||
|
? 'Check your email or contact your admin'
|
||||||
|
: 'Enter your email to request a password reset'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||||
|
{submitted ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg mb-6">
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
If an account with <strong>{email}</strong> exists, a password reset has been initiated. Contact your administrator for the reset link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? <LoadingSpinner size="sm" className="text-white" /> : null}
|
||||||
|
{loading ? 'Sending...' : 'Request Reset'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { Network, Eye, EyeOff } from 'lucide-react';
|
import { Network, Eye, EyeOff } from 'lucide-react';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
@@ -61,7 +61,15 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="block text-sm font-medium text-slate-700">Password</label>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
|||||||
192
src/pages/ResetPasswordPage.tsx
Normal file
192
src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Network, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.validateResetToken(token);
|
||||||
|
setEmail(data.email || '');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Invalid or expired reset link');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitError('');
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setSubmitError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setSubmitError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.resetPassword(token!, password);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setSubmitError(err.message || 'Failed to reset password');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 bg-red-100 rounded-2xl mb-4">
|
||||||
|
<Network className="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">Invalid Reset Link</h1>
|
||||||
|
<p className="text-slate-500 mb-6">{error}</p>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
← Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 bg-green-100 rounded-2xl mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">Password Reset</h1>
|
||||||
|
<p className="text-slate-500 mb-6">Your password has been successfully reset. You can now sign in with your new password.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
|
||||||
|
<Network className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Reset Password</h1>
|
||||||
|
<p className="text-slate-500 mt-1">Choose a new password for your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||||
|
{email && (
|
||||||
|
<div className="mb-6 p-3 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Resetting password for <strong>{email}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{submitError && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">New Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{submitting ? <LoadingSpinner size="sm" className="text-white" /> : null}
|
||||||
|
{submitting ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-sm text-slate-500 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
← Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user