Zelyanoth commited on
Commit
67b1bef
·
1 Parent(s): e484e19
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
- return {
169
- 'success': False,
170
- 'message': f'Login failed: {str(e)}'
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
- <Routes>
252
- <Route path="/" element={
253
- <Suspense fallback={<div className="mobile-loading-optimized">Loading...</div>}>
254
- <Home />
255
- </Suspense>
256
- } />
257
- <Route path="/login" element={<Login />} />
258
- <Route path="/register" element={<Register />} />
259
- <Route path="/linkedin/callback" element={<LinkedInCallbackHandler />} />
260
- </Routes>
 
 
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>&copy; 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
- <a href="#" className="font-medium text-primary-600 hover:text-primary-500 transition-colors focus:outline-none focus:underline">
 
 
 
 
262
  Forgot password?
263
- </a>
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>&copy; 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;