jj
Browse files- backend/api/auth.py +108 -0
- backend/services/auth_service.py +107 -5
- frontend/src/App.jsx +14 -10
- frontend/src/pages/ForgotPassword.jsx +189 -0
- frontend/src/pages/Login.jsx +6 -2
- frontend/src/pages/ResetPassword.jsx +309 -0
- frontend/src/services/authService.js +41 -0
- frontend/src/store/reducers/authSlice.js +98 -1
backend/api/auth.py
CHANGED
@@ -194,4 +194,112 @@ def get_current_user():
|
|
194 |
return jsonify({
|
195 |
'success': False,
|
196 |
'message': 'An error occurred while fetching user data'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
197 |
}), 500
|
|
|
194 |
return jsonify({
|
195 |
'success': False,
|
196 |
'message': 'An error occurred while fetching user data'
|
197 |
+
}), 500
|
198 |
+
|
199 |
+
|
200 |
+
@auth_bp.route('/forgot-password', methods=['OPTIONS'])
|
201 |
+
def handle_forgot_password_options():
|
202 |
+
"""Handle OPTIONS requests for preflight CORS checks for forgot password route."""
|
203 |
+
return '', 200
|
204 |
+
|
205 |
+
|
206 |
+
@auth_bp.route('/forgot-password', methods=['POST'])
|
207 |
+
def forgot_password():
|
208 |
+
"""
|
209 |
+
Request password reset for a user.
|
210 |
+
|
211 |
+
Request Body:
|
212 |
+
email (str): User email
|
213 |
+
|
214 |
+
Returns:
|
215 |
+
JSON: Password reset request result
|
216 |
+
"""
|
217 |
+
try:
|
218 |
+
data = request.get_json()
|
219 |
+
|
220 |
+
# Validate required fields
|
221 |
+
if not data or 'email' not in data:
|
222 |
+
return jsonify({
|
223 |
+
'success': False,
|
224 |
+
'message': 'Email is required'
|
225 |
+
}), 400
|
226 |
+
|
227 |
+
email = data['email']
|
228 |
+
|
229 |
+
# Request password reset
|
230 |
+
result = request_password_reset(current_app.supabase, email)
|
231 |
+
|
232 |
+
if result['success']:
|
233 |
+
return jsonify(result), 200
|
234 |
+
else:
|
235 |
+
return jsonify(result), 400
|
236 |
+
|
237 |
+
except Exception as e:
|
238 |
+
current_app.logger.error(f"Forgot password error: {str(e)}")
|
239 |
+
return jsonify({
|
240 |
+
'success': False,
|
241 |
+
'message': 'An error occurred while processing your request'
|
242 |
+
}), 500
|
243 |
+
|
244 |
+
|
245 |
+
@auth_bp.route('/reset-password', methods=['OPTIONS'])
|
246 |
+
def handle_reset_password_options():
|
247 |
+
"""Handle OPTIONS requests for preflight CORS checks for reset password route."""
|
248 |
+
return '', 200
|
249 |
+
|
250 |
+
|
251 |
+
@auth_bp.route('/reset-password', methods=['POST'])
|
252 |
+
def reset_password():
|
253 |
+
"""
|
254 |
+
Reset user password with token.
|
255 |
+
|
256 |
+
Request Body:
|
257 |
+
token (str): Password reset token
|
258 |
+
password (str): New password
|
259 |
+
confirm_password (str): Password confirmation
|
260 |
+
|
261 |
+
Returns:
|
262 |
+
JSON: Password reset result
|
263 |
+
"""
|
264 |
+
try:
|
265 |
+
data = request.get_json()
|
266 |
+
|
267 |
+
# Validate required fields
|
268 |
+
if not data or not all(k in data for k in ('token', 'password', 'confirm_password')):
|
269 |
+
return jsonify({
|
270 |
+
'success': False,
|
271 |
+
'message': 'Token, password, and confirm_password are required'
|
272 |
+
}), 400
|
273 |
+
|
274 |
+
token = data['token']
|
275 |
+
password = data['password']
|
276 |
+
confirm_password = data['confirm_password']
|
277 |
+
|
278 |
+
# Validate password confirmation
|
279 |
+
if password != confirm_password:
|
280 |
+
return jsonify({
|
281 |
+
'success': False,
|
282 |
+
'message': 'Passwords do not match'
|
283 |
+
}), 400
|
284 |
+
|
285 |
+
# Validate password length
|
286 |
+
if len(password) < 8:
|
287 |
+
return jsonify({
|
288 |
+
'success': False,
|
289 |
+
'message': 'Password must be at least 8 characters long'
|
290 |
+
}), 400
|
291 |
+
|
292 |
+
# Reset password
|
293 |
+
result = reset_user_password(current_app.supabase, token, password)
|
294 |
+
|
295 |
+
if result['success']:
|
296 |
+
return jsonify(result), 200
|
297 |
+
else:
|
298 |
+
return jsonify(result), 400
|
299 |
+
|
300 |
+
except Exception as e:
|
301 |
+
current_app.logger.error(f"Reset password error: {str(e)}")
|
302 |
+
return jsonify({
|
303 |
+
'success': False,
|
304 |
+
'message': 'An error occurred while resetting your password'
|
305 |
}), 500
|
backend/services/auth_service.py
CHANGED
@@ -2,6 +2,7 @@ from flask import current_app, request
|
|
2 |
from flask_jwt_extended import create_access_token, get_jwt
|
3 |
import bcrypt
|
4 |
from datetime import datetime, timedelta
|
|
|
5 |
from backend.models.user import User
|
6 |
from backend.utils.database import authenticate_user, create_user
|
7 |
|
@@ -165,10 +166,28 @@ def login_user(email: str, password: str, remember_me: bool = False) -> dict:
|
|
165 |
'message': 'No account found with this email. Please check your email or register for a new account.'
|
166 |
}
|
167 |
else:
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
172 |
|
173 |
def get_user_by_id(user_id: str) -> dict:
|
174 |
"""
|
@@ -195,4 +214,87 @@ def get_user_by_id(user_id: str) -> dict:
|
|
195 |
else:
|
196 |
return None
|
197 |
except Exception:
|
198 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
from flask_jwt_extended import create_access_token, get_jwt
|
3 |
import bcrypt
|
4 |
from datetime import datetime, timedelta
|
5 |
+
from supabase import Client
|
6 |
from backend.models.user import User
|
7 |
from backend.utils.database import authenticate_user, create_user
|
8 |
|
|
|
166 |
'message': 'No account found with this email. Please check your email or register for a new account.'
|
167 |
}
|
168 |
else:
|
169 |
+
error_str = str(e).lower()
|
170 |
+
if 'invalid credentials' in error_str or 'unauthorized' in error_str:
|
171 |
+
return {
|
172 |
+
'success': False,
|
173 |
+
'message': 'Password/email Incorrect'
|
174 |
+
}
|
175 |
+
elif 'email not confirmed' in error_str or 'email not verified' in error_str:
|
176 |
+
return {
|
177 |
+
'success': False,
|
178 |
+
'message': 'Check your mail to confirm your account',
|
179 |
+
'requires_confirmation': True
|
180 |
+
}
|
181 |
+
elif 'user not found' in error_str:
|
182 |
+
return {
|
183 |
+
'success': False,
|
184 |
+
'message': 'No account found with this email. Please check your email or register for a new account.'
|
185 |
+
}
|
186 |
+
else:
|
187 |
+
return {
|
188 |
+
'success': False,
|
189 |
+
'message': 'Password/email Incorrect'
|
190 |
+
}
|
191 |
|
192 |
def get_user_by_id(user_id: str) -> dict:
|
193 |
"""
|
|
|
214 |
else:
|
215 |
return None
|
216 |
except Exception:
|
217 |
+
return None
|
218 |
+
|
219 |
+
|
220 |
+
def request_password_reset(supabase: Client, email: str) -> dict:
|
221 |
+
"""
|
222 |
+
Request password reset for a user.
|
223 |
+
|
224 |
+
Args:
|
225 |
+
supabase (Client): Supabase client instance
|
226 |
+
email (str): User email
|
227 |
+
|
228 |
+
Returns:
|
229 |
+
dict: Password reset request result
|
230 |
+
"""
|
231 |
+
try:
|
232 |
+
# Request password reset
|
233 |
+
response = supabase.auth.reset_password_for_email(email)
|
234 |
+
|
235 |
+
return {
|
236 |
+
'success': True,
|
237 |
+
'message': 'Password reset instructions sent to your email. Please check your inbox.'
|
238 |
+
}
|
239 |
+
except Exception as e:
|
240 |
+
error_str = str(e).lower()
|
241 |
+
if 'user not found' in error_str:
|
242 |
+
# We don't want to reveal if a user exists or not for security reasons
|
243 |
+
# But we still return a success message to prevent user enumeration
|
244 |
+
return {
|
245 |
+
'success': True,
|
246 |
+
'message': 'If an account exists with this email, password reset instructions have been sent.'
|
247 |
+
}
|
248 |
+
else:
|
249 |
+
return {
|
250 |
+
'success': False,
|
251 |
+
'message': f'Failed to process password reset request: {str(e)}'
|
252 |
+
}
|
253 |
+
|
254 |
+
|
255 |
+
def reset_user_password(supabase: Client, token: str, new_password: str) -> dict:
|
256 |
+
"""
|
257 |
+
Reset user password with token.
|
258 |
+
|
259 |
+
Args:
|
260 |
+
supabase (Client): Supabase client instance
|
261 |
+
token (str): Password reset token (not directly used in Supabase v2)
|
262 |
+
new_password (str): New password
|
263 |
+
|
264 |
+
Returns:
|
265 |
+
dict: Password reset result
|
266 |
+
"""
|
267 |
+
try:
|
268 |
+
# In Supabase v2, we update the user's password directly
|
269 |
+
# The token verification is handled by Supabase when the user clicks the link
|
270 |
+
response = supabase.auth.update_user({
|
271 |
+
'password': new_password
|
272 |
+
})
|
273 |
+
|
274 |
+
if response.user:
|
275 |
+
return {
|
276 |
+
'success': True,
|
277 |
+
'message': 'Password reset successfully! You can now log in with your new password.'
|
278 |
+
}
|
279 |
+
else:
|
280 |
+
return {
|
281 |
+
'success': False,
|
282 |
+
'message': 'Failed to reset password. Please try again.'
|
283 |
+
}
|
284 |
+
except Exception as e:
|
285 |
+
error_str = str(e).lower()
|
286 |
+
if 'invalid token' in error_str or 'expired' in error_str:
|
287 |
+
return {
|
288 |
+
'success': False,
|
289 |
+
'message': 'Invalid or expired reset token. Please request a new password reset.'
|
290 |
+
}
|
291 |
+
elif 'password' in error_str:
|
292 |
+
return {
|
293 |
+
'success': False,
|
294 |
+
'message': 'Password does not meet requirements. Please use at least 8 characters.'
|
295 |
+
}
|
296 |
+
else:
|
297 |
+
return {
|
298 |
+
'success': False,
|
299 |
+
'message': f'Failed to reset password: {str(e)}'
|
300 |
+
}
|
frontend/src/App.jsx
CHANGED
@@ -5,6 +5,8 @@ import { getCurrentUser, checkCachedAuth, autoLogin } from './store/reducers/aut
|
|
5 |
import cookieService from './services/cookieService';
|
6 |
import Login from './pages/Login.jsx';
|
7 |
import Register from './pages/Register.jsx';
|
|
|
|
|
8 |
import Dashboard from './pages/Dashboard.jsx';
|
9 |
import Sources from './pages/Sources.jsx';
|
10 |
import Accounts from './pages/Accounts.jsx';
|
@@ -248,16 +250,18 @@ function App() {
|
|
248 |
</div>
|
249 |
</div>
|
250 |
) : (
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
|
|
|
|
261 |
)}
|
262 |
</div>
|
263 |
) : (
|
|
|
5 |
import cookieService from './services/cookieService';
|
6 |
import Login from './pages/Login.jsx';
|
7 |
import Register from './pages/Register.jsx';
|
8 |
+
import ForgotPassword from './pages/ForgotPassword.jsx';
|
9 |
+
import ResetPassword from './pages/ResetPassword.jsx';
|
10 |
import Dashboard from './pages/Dashboard.jsx';
|
11 |
import Sources from './pages/Sources.jsx';
|
12 |
import Accounts from './pages/Accounts.jsx';
|
|
|
250 |
</div>
|
251 |
</div>
|
252 |
) : (
|
253 |
+
<Routes>
|
254 |
+
<Route path="/" element={
|
255 |
+
<Suspense fallback={<div className="mobile-loading-optimized">Loading...</div>}>
|
256 |
+
<Home />
|
257 |
+
</Suspense>
|
258 |
+
} />
|
259 |
+
<Route path="/login" element={<Login />} />
|
260 |
+
<Route path="/register" element={<Register />} />
|
261 |
+
<Route path="/forgot-password" element={<ForgotPassword />} />
|
262 |
+
<Route path="/reset-password" element={<ResetPassword />} />
|
263 |
+
<Route path="/linkedin/callback" element={<LinkedInCallbackHandler />} />
|
264 |
+
</Routes>
|
265 |
)}
|
266 |
</div>
|
267 |
) : (
|
frontend/src/pages/ForgotPassword.jsx
ADDED
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState } from 'react';
|
2 |
+
import { useDispatch, useSelector } from 'react-redux';
|
3 |
+
import { useNavigate } from 'react-router-dom';
|
4 |
+
import { clearError } from '../store/reducers/authSlice';
|
5 |
+
import authService from '../services/authService';
|
6 |
+
|
7 |
+
const ForgotPassword = () => {
|
8 |
+
const dispatch = useDispatch();
|
9 |
+
const navigate = useNavigate();
|
10 |
+
const { loading, error } = useSelector(state => state.auth);
|
11 |
+
|
12 |
+
const [formData, setFormData] = useState({
|
13 |
+
email: ''
|
14 |
+
});
|
15 |
+
|
16 |
+
const [isFocused, setIsFocused] = useState({
|
17 |
+
email: false
|
18 |
+
});
|
19 |
+
|
20 |
+
const [success, setSuccess] = useState(false);
|
21 |
+
|
22 |
+
const handleChange = (e) => {
|
23 |
+
setFormData({
|
24 |
+
...formData,
|
25 |
+
[e.target.name]: e.target.value
|
26 |
+
});
|
27 |
+
};
|
28 |
+
|
29 |
+
const handleFocus = (field) => {
|
30 |
+
setIsFocused({
|
31 |
+
...isFocused,
|
32 |
+
[field]: true
|
33 |
+
});
|
34 |
+
};
|
35 |
+
|
36 |
+
const handleBlur = (field) => {
|
37 |
+
setIsFocused({
|
38 |
+
...isFocused,
|
39 |
+
[field]: false
|
40 |
+
});
|
41 |
+
};
|
42 |
+
|
43 |
+
const handleSubmit = async (e) => {
|
44 |
+
e.preventDefault();
|
45 |
+
|
46 |
+
// Prevent form submission if already loading
|
47 |
+
if (loading === 'pending') {
|
48 |
+
return;
|
49 |
+
}
|
50 |
+
|
51 |
+
try {
|
52 |
+
await authService.forgotPassword(formData.email);
|
53 |
+
setSuccess(true);
|
54 |
+
// Clear form
|
55 |
+
setFormData({ email: '' });
|
56 |
+
} catch (err) {
|
57 |
+
console.error('Password reset request failed:', err);
|
58 |
+
}
|
59 |
+
};
|
60 |
+
|
61 |
+
const handleBackToLogin = () => {
|
62 |
+
dispatch(clearError());
|
63 |
+
navigate('/login');
|
64 |
+
};
|
65 |
+
|
66 |
+
return (
|
67 |
+
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-accent-50 flex items-center justify-center p-3 sm:p-4 animate-fade-in">
|
68 |
+
<div className="w-full max-w-sm sm:max-w-md">
|
69 |
+
{/* Logo and Brand */}
|
70 |
+
<div className="text-center mb-6 sm:mb-8 animate-slide-up">
|
71 |
+
<div className="inline-flex items-center justify-center w-14 h-14 sm:w-16 sm:h-16 bg-gradient-to-br from-primary-600 to-primary-800 rounded-2xl shadow-lg mb-3 sm:mb-4">
|
72 |
+
<span className="text-xl sm:text-2xl font-bold text-white">Lin</span>
|
73 |
+
</div>
|
74 |
+
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-1 sm:mb-2">Reset Password</h1>
|
75 |
+
<p className="text-sm sm:text-base text-gray-600">
|
76 |
+
{success
|
77 |
+
? 'Check your email for password reset instructions'
|
78 |
+
: 'Enter your email to receive password reset instructions'}
|
79 |
+
</p>
|
80 |
+
</div>
|
81 |
+
|
82 |
+
{/* Auth Card */}
|
83 |
+
<div className="bg-white rounded-2xl shadow-xl p-4 sm:p-8 space-y-4 sm:space-y-6 animate-slide-up animate-delay-100">
|
84 |
+
{/* Success Message */}
|
85 |
+
{success && (
|
86 |
+
<div className="bg-green-50 border border-green-200 rounded-lg p-3 sm:p-4 animate-slide-up animate-delay-200">
|
87 |
+
<div className="flex items-start space-x-2">
|
88 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
89 |
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
90 |
+
</svg>
|
91 |
+
<span className="text-green-700 text-xs sm:text-sm font-medium">
|
92 |
+
Password reset instructions sent to your email. Please check your inbox.
|
93 |
+
</span>
|
94 |
+
</div>
|
95 |
+
</div>
|
96 |
+
)}
|
97 |
+
|
98 |
+
{/* Error Message */}
|
99 |
+
{error && !success && (
|
100 |
+
<div className="bg-red-50 border border-red-200 rounded-lg p-3 sm:p-4 animate-slide-up animate-delay-200">
|
101 |
+
<div className="flex items-start space-x-2">
|
102 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
103 |
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
104 |
+
</svg>
|
105 |
+
<span className="text-red-700 text-xs sm:text-sm font-medium">{error}</span>
|
106 |
+
</div>
|
107 |
+
</div>
|
108 |
+
)}
|
109 |
+
|
110 |
+
{!success && (
|
111 |
+
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
|
112 |
+
{/* Email Field */}
|
113 |
+
<div className="space-y-2">
|
114 |
+
<label htmlFor="email" className="block text-xs sm:text-sm font-semibold text-gray-700">
|
115 |
+
Email Address
|
116 |
+
</label>
|
117 |
+
<div className="relative">
|
118 |
+
<input
|
119 |
+
type="email"
|
120 |
+
id="email"
|
121 |
+
name="email"
|
122 |
+
value={formData.email}
|
123 |
+
onChange={handleChange}
|
124 |
+
onFocus={() => handleFocus('email')}
|
125 |
+
onBlur={() => handleBlur('email')}
|
126 |
+
className={`w-full px-3 sm:px-4 py-2 sm:py-3 rounded-xl border-2 transition-all duration-200 ${
|
127 |
+
isFocused.email
|
128 |
+
? 'border-primary-500 shadow-md'
|
129 |
+
: 'border-gray-200 hover:border-gray-300'
|
130 |
+
} ${formData.email ? 'text-gray-900' : 'text-gray-500'} focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 touch-manipulation`}
|
131 |
+
placeholder="Enter your email"
|
132 |
+
required
|
133 |
+
aria-required="true"
|
134 |
+
aria-label="Email address"
|
135 |
+
/>
|
136 |
+
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
137 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
138 |
+
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
139 |
+
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
140 |
+
</svg>
|
141 |
+
</div>
|
142 |
+
</div>
|
143 |
+
</div>
|
144 |
+
|
145 |
+
{/* Submit Button */}
|
146 |
+
<button
|
147 |
+
type="submit"
|
148 |
+
disabled={loading === 'pending'}
|
149 |
+
className="w-full bg-gradient-to-r from-primary-600 to-primary-800 text-white font-semibold py-2.5 sm:py-3 px-4 rounded-xl hover:from-primary-700 hover:to-primary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none touch-manipulation"
|
150 |
+
aria-busy={loading === 'pending'}
|
151 |
+
>
|
152 |
+
{loading === 'pending' ? (
|
153 |
+
<div className="flex items-center justify-center">
|
154 |
+
<svg className="animate-spin -ml-1 mr-2 sm:mr-3 h-4 w-4 sm:h-5 sm:w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
155 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
156 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
157 |
+
</svg>
|
158 |
+
<span className="text-xs sm:text-sm">Sending...</span>
|
159 |
+
</div>
|
160 |
+
) : (
|
161 |
+
<span className="text-xs sm:text-sm">Send Reset Instructions</span>
|
162 |
+
)}
|
163 |
+
</button>
|
164 |
+
</form>
|
165 |
+
)}
|
166 |
+
|
167 |
+
{/* Back to Login Link */}
|
168 |
+
<div className="text-center">
|
169 |
+
<button
|
170 |
+
type="button"
|
171 |
+
onClick={handleBackToLogin}
|
172 |
+
className="font-semibold text-primary-600 hover:text-primary-500 transition-colors focus:outline-none focus:underline text-xs sm:text-sm"
|
173 |
+
aria-label="Back to login"
|
174 |
+
>
|
175 |
+
Back to Sign In
|
176 |
+
</button>
|
177 |
+
</div>
|
178 |
+
</div>
|
179 |
+
|
180 |
+
{/* Footer */}
|
181 |
+
<div className="text-center mt-6 sm:mt-8 text-xs text-gray-500">
|
182 |
+
<p>© 2024 Lin. All rights reserved.</p>
|
183 |
+
</div>
|
184 |
+
</div>
|
185 |
+
</div>
|
186 |
+
);
|
187 |
+
};
|
188 |
+
|
189 |
+
export default ForgotPassword;
|
frontend/src/pages/Login.jsx
CHANGED
@@ -258,9 +258,13 @@ const Login = () => {
|
|
258 |
</label>
|
259 |
</div>
|
260 |
<div className="text-xs sm:text-sm">
|
261 |
-
<
|
|
|
|
|
|
|
|
|
262 |
Forgot password?
|
263 |
-
</
|
264 |
</div>
|
265 |
</div>
|
266 |
|
|
|
258 |
</label>
|
259 |
</div>
|
260 |
<div className="text-xs sm:text-sm">
|
261 |
+
<button
|
262 |
+
type="button"
|
263 |
+
onClick={() => navigate('/forgot-password')}
|
264 |
+
className="font-medium text-primary-600 hover:text-primary-500 transition-colors focus:outline-none focus:underline"
|
265 |
+
>
|
266 |
Forgot password?
|
267 |
+
</button>
|
268 |
</div>
|
269 |
</div>
|
270 |
|
frontend/src/pages/ResetPassword.jsx
ADDED
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { useDispatch, useSelector } from 'react-redux';
|
3 |
+
import { useNavigate, useSearchParams } from 'react-router-dom';
|
4 |
+
import { resetPassword, clearError } from '../store/reducers/authSlice';
|
5 |
+
|
6 |
+
const ResetPassword = () => {
|
7 |
+
const dispatch = useDispatch();
|
8 |
+
const navigate = useNavigate();
|
9 |
+
const [searchParams] = useSearchParams();
|
10 |
+
const { loading, error } = useSelector(state => state.auth);
|
11 |
+
|
12 |
+
const [formData, setFormData] = useState({
|
13 |
+
token: '',
|
14 |
+
password: '',
|
15 |
+
confirmPassword: ''
|
16 |
+
});
|
17 |
+
|
18 |
+
const [showPassword, setShowPassword] = useState(false);
|
19 |
+
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
20 |
+
const [passwordStrength, setPasswordStrength] = useState(0);
|
21 |
+
const [isFocused, setIsFocused] = useState({
|
22 |
+
password: false,
|
23 |
+
confirmPassword: false
|
24 |
+
});
|
25 |
+
|
26 |
+
// Get token from URL params
|
27 |
+
useEffect(() => {
|
28 |
+
const token = searchParams.get('token');
|
29 |
+
if (token) {
|
30 |
+
setFormData(prev => ({ ...prev, token }));
|
31 |
+
}
|
32 |
+
}, [searchParams]);
|
33 |
+
|
34 |
+
const calculatePasswordStrength = (password) => {
|
35 |
+
let strength = 0;
|
36 |
+
|
37 |
+
// Length check
|
38 |
+
if (password.length >= 8) strength += 1;
|
39 |
+
if (password.length >= 12) strength += 1;
|
40 |
+
|
41 |
+
// Character variety checks
|
42 |
+
if (/[a-z]/.test(password)) strength += 1;
|
43 |
+
if (/[A-Z]/.test(password)) strength += 1;
|
44 |
+
if (/[0-9]/.test(password)) strength += 1;
|
45 |
+
if (/[^A-Za-z0-9]/.test(password)) strength += 1;
|
46 |
+
|
47 |
+
setPasswordStrength(Math.min(strength, 6));
|
48 |
+
};
|
49 |
+
|
50 |
+
const handleChange = (e) => {
|
51 |
+
const { name, value } = e.target;
|
52 |
+
setFormData({
|
53 |
+
...formData,
|
54 |
+
[name]: value
|
55 |
+
});
|
56 |
+
|
57 |
+
// Calculate password strength
|
58 |
+
if (name === 'password') {
|
59 |
+
calculatePasswordStrength(value);
|
60 |
+
}
|
61 |
+
};
|
62 |
+
|
63 |
+
const handleFocus = (field) => {
|
64 |
+
setIsFocused({
|
65 |
+
...isFocused,
|
66 |
+
[field]: true
|
67 |
+
});
|
68 |
+
};
|
69 |
+
|
70 |
+
const handleBlur = (field) => {
|
71 |
+
setIsFocused({
|
72 |
+
...isFocused,
|
73 |
+
[field]: false
|
74 |
+
});
|
75 |
+
};
|
76 |
+
|
77 |
+
const togglePasswordVisibility = () => {
|
78 |
+
setShowPassword(!showPassword);
|
79 |
+
};
|
80 |
+
|
81 |
+
const toggleConfirmPasswordVisibility = () => {
|
82 |
+
setShowConfirmPassword(!showConfirmPassword);
|
83 |
+
};
|
84 |
+
|
85 |
+
const handleSubmit = async (e) => {
|
86 |
+
e.preventDefault();
|
87 |
+
|
88 |
+
// Basic validation
|
89 |
+
if (formData.password !== formData.confirmPassword) {
|
90 |
+
alert('Passwords do not match');
|
91 |
+
return;
|
92 |
+
}
|
93 |
+
|
94 |
+
if (formData.password.length < 8) {
|
95 |
+
alert('Password must be at least 8 characters long');
|
96 |
+
return;
|
97 |
+
}
|
98 |
+
|
99 |
+
try {
|
100 |
+
await dispatch(resetPassword(formData)).unwrap();
|
101 |
+
// Show success message and redirect to login
|
102 |
+
alert('Password reset successfully! You can now log in with your new password.');
|
103 |
+
navigate('/login');
|
104 |
+
} catch (err) {
|
105 |
+
// Error is handled by the Redux slice
|
106 |
+
console.error('Password reset failed:', err);
|
107 |
+
}
|
108 |
+
};
|
109 |
+
|
110 |
+
const handleBackToLogin = () => {
|
111 |
+
dispatch(clearError());
|
112 |
+
navigate('/login');
|
113 |
+
};
|
114 |
+
|
115 |
+
return (
|
116 |
+
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-accent-50 flex items-center justify-center p-3 sm:p-4 animate-fade-in">
|
117 |
+
<div className="w-full max-w-sm sm:max-w-md">
|
118 |
+
{/* Logo and Brand */}
|
119 |
+
<div className="text-center mb-6 sm:mb-8 animate-slide-up">
|
120 |
+
<div className="inline-flex items-center justify-center w-14 h-14 sm:w-16 sm:h-16 bg-gradient-to-br from-primary-600 to-primary-800 rounded-2xl shadow-lg mb-3 sm:mb-4">
|
121 |
+
<span className="text-xl sm:text-2xl font-bold text-white">Lin</span>
|
122 |
+
</div>
|
123 |
+
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-1 sm:mb-2">Reset Password</h1>
|
124 |
+
<p className="text-sm sm:text-base text-gray-600">Enter your new password below</p>
|
125 |
+
</div>
|
126 |
+
|
127 |
+
{/* Auth Card */}
|
128 |
+
<div className="bg-white rounded-2xl shadow-xl p-4 sm:p-8 space-y-4 sm:space-y-6 animate-slide-up animate-delay-100">
|
129 |
+
{/* Error Message */}
|
130 |
+
{error && (
|
131 |
+
<div className="bg-red-50 border border-red-200 rounded-lg p-3 sm:p-4 animate-slide-up animate-delay-200">
|
132 |
+
<div className="flex items-start space-x-2">
|
133 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
134 |
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
135 |
+
</svg>
|
136 |
+
<span className="text-red-700 text-xs sm:text-sm font-medium">{error}</span>
|
137 |
+
</div>
|
138 |
+
</div>
|
139 |
+
)}
|
140 |
+
|
141 |
+
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
|
142 |
+
{/* Password Field */}
|
143 |
+
<div className="space-y-2">
|
144 |
+
<label htmlFor="password" className="block text-xs sm:text-sm font-semibold text-gray-700">
|
145 |
+
New Password
|
146 |
+
</label>
|
147 |
+
<div className="relative">
|
148 |
+
<input
|
149 |
+
type={showPassword ? "text" : "password"}
|
150 |
+
id="password"
|
151 |
+
name="password"
|
152 |
+
value={formData.password}
|
153 |
+
onChange={handleChange}
|
154 |
+
onFocus={() => handleFocus('password')}
|
155 |
+
onBlur={() => handleBlur('password')}
|
156 |
+
className={`w-full px-3 sm:px-4 py-2 sm:py-3 rounded-xl border-2 transition-all duration-200 ${
|
157 |
+
isFocused.password
|
158 |
+
? 'border-primary-500 shadow-md'
|
159 |
+
: 'border-gray-200 hover:border-gray-300'
|
160 |
+
} ${formData.password ? 'text-gray-900' : 'text-gray-500'} focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 touch-manipulation`}
|
161 |
+
placeholder="Create a new password"
|
162 |
+
required
|
163 |
+
aria-required="true"
|
164 |
+
aria-label="New password"
|
165 |
+
/>
|
166 |
+
<button
|
167 |
+
type="button"
|
168 |
+
onClick={togglePasswordVisibility}
|
169 |
+
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 transition-colors touch-manipulation"
|
170 |
+
aria-label={showPassword ? "Hide password" : "Show password"}
|
171 |
+
>
|
172 |
+
{showPassword ? (
|
173 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="currentColor" viewBox="0 0 20 20">
|
174 |
+
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
175 |
+
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
176 |
+
</svg>
|
177 |
+
) : (
|
178 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="currentColor" viewBox="0 0 20 20">
|
179 |
+
<path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" />
|
180 |
+
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
181 |
+
</svg>
|
182 |
+
)}
|
183 |
+
</button>
|
184 |
+
</div>
|
185 |
+
|
186 |
+
{/* Password Strength Indicator */}
|
187 |
+
{formData.password && (
|
188 |
+
<div className="space-y-1">
|
189 |
+
<div className="flex justify-between text-xs">
|
190 |
+
<span className="text-gray-600">Password strength</span>
|
191 |
+
<span className={`font-medium ${
|
192 |
+
passwordStrength <= 2 ? 'text-red-600' :
|
193 |
+
passwordStrength <= 4 ? 'text-yellow-600' :
|
194 |
+
'text-green-600'
|
195 |
+
}`}>
|
196 |
+
{passwordStrength <= 2 ? 'Weak' :
|
197 |
+
passwordStrength <= 4 ? 'Fair' :
|
198 |
+
passwordStrength === 5 ? 'Good' : 'Strong'}
|
199 |
+
</span>
|
200 |
+
</div>
|
201 |
+
<div className="w-full bg-gray-200 rounded-full h-1.5 sm:h-2">
|
202 |
+
<div
|
203 |
+
className={`h-1.5 sm:h-2 rounded-full transition-all duration-300 ${
|
204 |
+
passwordStrength <= 2 ? 'bg-red-500 w-1/3' :
|
205 |
+
passwordStrength <= 4 ? 'bg-yellow-500 w-2/3' :
|
206 |
+
passwordStrength === 5 ? 'bg-green-500 w-4/5' :
|
207 |
+
'bg-green-600 w-full'
|
208 |
+
}`}
|
209 |
+
></div>
|
210 |
+
</div>
|
211 |
+
<div className="text-xs text-gray-500">
|
212 |
+
Use 8+ characters with uppercase, lowercase, numbers, and symbols
|
213 |
+
</div>
|
214 |
+
</div>
|
215 |
+
)}
|
216 |
+
</div>
|
217 |
+
|
218 |
+
{/* Confirm Password Field */}
|
219 |
+
<div className="space-y-2">
|
220 |
+
<label htmlFor="confirmPassword" className="block text-xs sm:text-sm font-semibold text-gray-700">
|
221 |
+
Confirm New Password
|
222 |
+
</label>
|
223 |
+
<div className="relative">
|
224 |
+
<input
|
225 |
+
type={showConfirmPassword ? "text" : "password"}
|
226 |
+
id="confirmPassword"
|
227 |
+
name="confirmPassword"
|
228 |
+
value={formData.confirmPassword}
|
229 |
+
onChange={handleChange}
|
230 |
+
onFocus={() => handleFocus('confirmPassword')}
|
231 |
+
onBlur={() => handleBlur('confirmPassword')}
|
232 |
+
className={`w-full px-3 sm:px-4 py-2 sm:py-3 rounded-xl border-2 transition-all duration-200 ${
|
233 |
+
isFocused.confirmPassword
|
234 |
+
? 'border-primary-500 shadow-md'
|
235 |
+
: 'border-gray-200 hover:border-gray-300'
|
236 |
+
} ${formData.confirmPassword ? 'text-gray-900' : 'text-gray-500'} focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 touch-manipulation`}
|
237 |
+
placeholder="Confirm your new password"
|
238 |
+
required
|
239 |
+
aria-required="true"
|
240 |
+
aria-label="Confirm new password"
|
241 |
+
/>
|
242 |
+
<button
|
243 |
+
type="button"
|
244 |
+
onClick={toggleConfirmPasswordVisibility}
|
245 |
+
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 transition-colors touch-manipulation"
|
246 |
+
aria-label={showConfirmPassword ? "Hide confirm password" : "Show confirm password"}
|
247 |
+
>
|
248 |
+
{showConfirmPassword ? (
|
249 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="currentColor" viewBox="0 0 20 20">
|
250 |
+
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
251 |
+
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
252 |
+
</svg>
|
253 |
+
) : (
|
254 |
+
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="currentColor" viewBox="0 0 20 20">
|
255 |
+
<path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" />
|
256 |
+
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
257 |
+
</svg>
|
258 |
+
)}
|
259 |
+
</button>
|
260 |
+
</div>
|
261 |
+
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
262 |
+
<p className="text-red-600 text-xs">Passwords do not match</p>
|
263 |
+
)}
|
264 |
+
</div>
|
265 |
+
|
266 |
+
{/* Submit Button */}
|
267 |
+
<button
|
268 |
+
type="submit"
|
269 |
+
disabled={loading === 'pending'}
|
270 |
+
className="w-full bg-gradient-to-r from-primary-600 to-primary-800 text-white font-semibold py-2.5 sm:py-3 px-4 rounded-xl hover:from-primary-700 hover:to-primary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none touch-manipulation"
|
271 |
+
aria-busy={loading === 'pending'}
|
272 |
+
>
|
273 |
+
{loading === 'pending' ? (
|
274 |
+
<div className="flex items-center justify-center">
|
275 |
+
<svg className="animate-spin -ml-1 mr-2 sm:mr-3 h-4 w-4 sm:h-5 sm:w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
276 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
277 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
278 |
+
</svg>
|
279 |
+
<span className="text-xs sm:text-sm">Resetting password...</span>
|
280 |
+
</div>
|
281 |
+
) : (
|
282 |
+
<span className="text-xs sm:text-sm">Reset Password</span>
|
283 |
+
)}
|
284 |
+
</button>
|
285 |
+
</form>
|
286 |
+
|
287 |
+
{/* Back to Login Link */}
|
288 |
+
<div className="text-center">
|
289 |
+
<button
|
290 |
+
type="button"
|
291 |
+
onClick={handleBackToLogin}
|
292 |
+
className="font-semibold text-primary-600 hover:text-primary-500 transition-colors focus:outline-none focus:underline text-xs sm:text-sm"
|
293 |
+
aria-label="Back to login"
|
294 |
+
>
|
295 |
+
Back to Sign In
|
296 |
+
</button>
|
297 |
+
</div>
|
298 |
+
</div>
|
299 |
+
|
300 |
+
{/* Footer */}
|
301 |
+
<div className="text-center mt-6 sm:mt-8 text-xs text-gray-500">
|
302 |
+
<p>© 2024 Lin. All rights reserved.</p>
|
303 |
+
</div>
|
304 |
+
</div>
|
305 |
+
</div>
|
306 |
+
);
|
307 |
+
};
|
308 |
+
|
309 |
+
export default ResetPassword;
|
frontend/src/services/authService.js
CHANGED
@@ -85,6 +85,47 @@ class AuthService {
|
|
85 |
throw error;
|
86 |
}
|
87 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
}
|
89 |
|
90 |
// Export singleton instance
|
|
|
85 |
throw error;
|
86 |
}
|
87 |
}
|
88 |
+
|
89 |
+
/**
|
90 |
+
* Request password reset
|
91 |
+
* @param {string} email - User email
|
92 |
+
* @returns {Promise<Object>} - API response
|
93 |
+
*/
|
94 |
+
async forgotPassword(email) {
|
95 |
+
try {
|
96 |
+
const response = await apiClient.post('/auth/forgot-password', { email });
|
97 |
+
return response;
|
98 |
+
} catch (error) {
|
99 |
+
console.error('AuthService: Forgot password error', error);
|
100 |
+
// Handle network errors
|
101 |
+
if (!error.response) {
|
102 |
+
throw new Error('Network error - please check your connection');
|
103 |
+
}
|
104 |
+
throw error;
|
105 |
+
}
|
106 |
+
}
|
107 |
+
|
108 |
+
/**
|
109 |
+
* Reset password with token
|
110 |
+
* @param {Object} resetData - Password reset data
|
111 |
+
* @param {string} resetData.token - Reset token
|
112 |
+
* @param {string} resetData.password - New password
|
113 |
+
* @param {string} resetData.confirm_password - Password confirmation
|
114 |
+
* @returns {Promise<Object>} - API response
|
115 |
+
*/
|
116 |
+
async resetPassword(resetData) {
|
117 |
+
try {
|
118 |
+
const response = await apiClient.post('/auth/reset-password', resetData);
|
119 |
+
return response;
|
120 |
+
} catch (error) {
|
121 |
+
console.error('AuthService: Reset password error', error);
|
122 |
+
// Handle network errors
|
123 |
+
if (!error.response) {
|
124 |
+
throw new Error('Network error - please check your connection');
|
125 |
+
}
|
126 |
+
throw error;
|
127 |
+
}
|
128 |
+
}
|
129 |
}
|
130 |
|
131 |
// Export singleton instance
|
frontend/src/store/reducers/authSlice.js
CHANGED
@@ -266,6 +266,30 @@ export const loginUser = createAsyncThunk(
|
|
266 |
}
|
267 |
);
|
268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
269 |
export const logoutUser = createAsyncThunk(
|
270 |
'auth/logout',
|
271 |
async (_, { rejectWithValue }) => {
|
@@ -466,7 +490,7 @@ const authSlice = createSlice({
|
|
466 |
const errorMsg = errorPayload.message.toLowerCase();
|
467 |
if (errorMsg.includes('email not confirmed') || errorMsg.includes('email not verified') || errorPayload.requires_confirmation) {
|
468 |
errorMessage = 'Check your mail to confirm your account';
|
469 |
-
} else if (errorMsg.includes('invalid credentials') || errorMsg.includes('invalid email') || errorMsg.includes('invalid password')) {
|
470 |
errorMessage = 'Password/email Incorrect';
|
471 |
} else if (errorMsg.includes('user not found')) {
|
472 |
errorMessage = 'No account found with this email. Please check your email or register for a new account.';
|
@@ -504,6 +528,68 @@ const authSlice = createSlice({
|
|
504 |
localStorage.removeItem('token');
|
505 |
})
|
506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
507 |
// Get current user (existing)
|
508 |
.addCase(getCurrentUser.pending, (state) => {
|
509 |
state.loading = 'pending';
|
@@ -530,4 +616,15 @@ export const {
|
|
530 |
setRememberMe
|
531 |
} = authSlice.actions;
|
532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
533 |
export default authSlice.reducer;
|
|
|
266 |
}
|
267 |
);
|
268 |
|
269 |
+
export const forgotPassword = createAsyncThunk(
|
270 |
+
'auth/forgotPassword',
|
271 |
+
async (email, { rejectWithValue }) => {
|
272 |
+
try {
|
273 |
+
const response = await authService.forgotPassword(email);
|
274 |
+
return response.data;
|
275 |
+
} catch (error) {
|
276 |
+
return rejectWithValue(error.response?.data || { success: false, message: 'Password reset request failed' });
|
277 |
+
}
|
278 |
+
}
|
279 |
+
);
|
280 |
+
|
281 |
+
export const resetPassword = createAsyncThunk(
|
282 |
+
'auth/resetPassword',
|
283 |
+
async (resetData, { rejectWithValue }) => {
|
284 |
+
try {
|
285 |
+
const response = await authService.resetPassword(resetData);
|
286 |
+
return response.data;
|
287 |
+
} catch (error) {
|
288 |
+
return rejectWithValue(error.response?.data || { success: false, message: 'Password reset failed' });
|
289 |
+
}
|
290 |
+
}
|
291 |
+
);
|
292 |
+
|
293 |
export const logoutUser = createAsyncThunk(
|
294 |
'auth/logout',
|
295 |
async (_, { rejectWithValue }) => {
|
|
|
490 |
const errorMsg = errorPayload.message.toLowerCase();
|
491 |
if (errorMsg.includes('email not confirmed') || errorMsg.includes('email not verified') || errorPayload.requires_confirmation) {
|
492 |
errorMessage = 'Check your mail to confirm your account';
|
493 |
+
} else if (errorMsg.includes('invalid credentials') || errorMsg.includes('invalid email') || errorMsg.includes('invalid password') || errorMsg.includes('login failed')) {
|
494 |
errorMessage = 'Password/email Incorrect';
|
495 |
} else if (errorMsg.includes('user not found')) {
|
496 |
errorMessage = 'No account found with this email. Please check your email or register for a new account.';
|
|
|
528 |
localStorage.removeItem('token');
|
529 |
})
|
530 |
|
531 |
+
// Forgot password
|
532 |
+
.addCase(forgotPassword.pending, (state) => {
|
533 |
+
state.loading = 'pending';
|
534 |
+
state.error = null;
|
535 |
+
})
|
536 |
+
.addCase(forgotPassword.fulfilled, (state, action) => {
|
537 |
+
state.loading = 'succeeded';
|
538 |
+
state.error = null;
|
539 |
+
})
|
540 |
+
.addCase(forgotPassword.rejected, (state, action) => {
|
541 |
+
state.loading = 'failed';
|
542 |
+
|
543 |
+
// Handle different error types with specific messages
|
544 |
+
const errorPayload = action.payload;
|
545 |
+
let errorMessage = 'Password reset request failed';
|
546 |
+
|
547 |
+
if (errorPayload) {
|
548 |
+
if (errorPayload.message) {
|
549 |
+
errorMessage = errorPayload.message;
|
550 |
+
} else if (typeof errorPayload === 'string') {
|
551 |
+
errorMessage = errorPayload;
|
552 |
+
}
|
553 |
+
}
|
554 |
+
|
555 |
+
state.error = errorMessage;
|
556 |
+
})
|
557 |
+
|
558 |
+
// Reset password
|
559 |
+
.addCase(resetPassword.pending, (state) => {
|
560 |
+
state.loading = 'pending';
|
561 |
+
state.error = null;
|
562 |
+
})
|
563 |
+
.addCase(resetPassword.fulfilled, (state, action) => {
|
564 |
+
state.loading = 'succeeded';
|
565 |
+
state.error = null;
|
566 |
+
})
|
567 |
+
.addCase(resetPassword.rejected, (state, action) => {
|
568 |
+
state.loading = 'failed';
|
569 |
+
|
570 |
+
// Handle different error types with specific messages
|
571 |
+
const errorPayload = action.payload;
|
572 |
+
let errorMessage = 'Password reset failed';
|
573 |
+
|
574 |
+
if (errorPayload) {
|
575 |
+
if (errorPayload.message) {
|
576 |
+
// Check for specific error types
|
577 |
+
const errorMsg = errorPayload.message.toLowerCase();
|
578 |
+
if (errorMsg.includes('token')) {
|
579 |
+
errorMessage = 'Invalid or expired reset token. Please request a new password reset.';
|
580 |
+
} else if (errorMsg.includes('password')) {
|
581 |
+
errorMessage = 'Password does not meet requirements. Please use at least 8 characters.';
|
582 |
+
} else {
|
583 |
+
errorMessage = errorPayload.message;
|
584 |
+
}
|
585 |
+
} else if (typeof errorPayload === 'string') {
|
586 |
+
errorMessage = errorPayload;
|
587 |
+
}
|
588 |
+
}
|
589 |
+
|
590 |
+
state.error = errorMessage;
|
591 |
+
})
|
592 |
+
|
593 |
// Get current user (existing)
|
594 |
.addCase(getCurrentUser.pending, (state) => {
|
595 |
state.loading = 'pending';
|
|
|
616 |
setRememberMe
|
617 |
} = authSlice.actions;
|
618 |
|
619 |
+
export {
|
620 |
+
registerUser,
|
621 |
+
loginUser,
|
622 |
+
logoutUser,
|
623 |
+
getCurrentUser,
|
624 |
+
checkCachedAuth,
|
625 |
+
autoLogin,
|
626 |
+
forgotPassword,
|
627 |
+
resetPassword
|
628 |
+
};
|
629 |
+
|
630 |
export default authSlice.reducer;
|