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;
|