diff --git a/financial-tracker/api-gateway/app/app.py b/financial-tracker/api-gateway/app/app.py index c3961c4e73dc3dc6d3f76873775ab59aded6259c..f259d78c5a8f22e1545bd005f4da4e708fcad194 100644 --- a/financial-tracker/api-gateway/app/app.py +++ b/financial-tracker/api-gateway/app/app.py @@ -94,9 +94,102 @@ def me(): except requests.exceptions.RequestException as e: app.logger.error(f"Error contacting user-service: {e}") return jsonify({"error": "User service unavailable"}), 503 + + +@app.route('/auth/update', methods=["PUT"]) +def update_profile(): + """ + Forward request to update user profile to the user-service. + """ + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + + if not token.startswith("Bearer "): + app.logger.error("Invalid token format received.") + return jsonify({"error": "Invalid token format. Expected 'Bearer <token>'"}), 400 + + try: + app.logger.info("Forwarding user update request to user-service.") + headers = {"Authorization": token} + + # Forward the PUT request body to user-service + response = requests.put(f"{USER_SERVICE_URL}/auth/update", json=request.json, headers=headers) + + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting user-service: {e}") + return jsonify({"error": "User service unavailable"}), 503 + + +@app.route('/auth/password', methods=["PUT"]) +def update_password(): + """ + Forward request to update user password to the user-service. + """ + token = request.headers.get("Authorization") # Get token from headers -# Configure transaction service URL -TRANSACTION_SERVICE_URL = os.getenv('TRANSACTION_SERVICE_URL', 'http://transaction-service:5002') + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + + if not token.startswith("Bearer "): + app.logger.error("Invalid token format received.") + return jsonify({"error": "Invalid token format. Expected 'Bearer <token>'"}), 400 + + try: + app.logger.info("Forwarding password update request to user-service.") + headers = {"Authorization": token} + + # Forward the PUT request body to user-service + response = requests.put(f"{USER_SERVICE_URL}/auth/password", json=request.json, headers=headers) + + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting user-service: {e}") + return jsonify({"error": "User service unavailable"}), 503 + + +@app.route('/currencies', methods=["GET"]) +@jwt_required() +def fetch_currencies(): + token = request.headers.get("Authorization") # Get token from headers + + if not token: + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + headers = {"Authorization": token} + response = requests.get(f"{USER_SERVICE_URL}/currencies", headers=headers) + response_data = jsonify(response.json()) + + return response_data, response.status_code + except requests.exceptions.RequestException as e: + return jsonify({"error": "User service unavailable"}), 503 + +@app.route('/timezones', methods=["GET"]) +@jwt_required() +def fetch_timezones(): + token = request.headers.get("Authorization") # Get token from headers + + if not token: + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + headers = {"Authorization": token} + response = requests.get(f"{USER_SERVICE_URL}/timezones", headers=headers) + response_data = jsonify(response.json()) + + return response_data, response.status_code + except requests.exceptions.RequestException as e: + return jsonify({"error": "User service unavailable"}), 503 + @app.route('/transactions', methods=["GET", "POST"]) def handle_transactions(): @@ -283,7 +376,7 @@ def add_income(): response = requests.post(f"{TRANSACTION_SERVICE_URL}/income", headers=headers, json=request.get_json()) return jsonify(response.json()), response.status_code - except requests.exceptions.RequestException: + except requests.exceptions.RequestException as e: app.logger.error(f"Error contacting transaction-service: {e}") return jsonify({"error": "Transaction service unavailable"}), 503 @@ -324,7 +417,7 @@ def update_income(income_id): response = requests.put(f"{TRANSACTION_SERVICE_URL}/income/{income_id}", headers=headers, json=request.get_json()) return jsonify(response.json()), response.status_code - except requests.exceptions.RequestException: + except requests.exceptions.RequestException as e: app.logger.error(f"Error contacting transaction-service: {e}") return jsonify({"error": "Transaction service unavailable"}), 503 @@ -348,7 +441,7 @@ def delete_income(income_id): app.logger.error(f"Failed to delete income with ID {income_id}.") return jsonify({"error": "Failed to delete income"}), response.status_code - except requests.exceptions.RequestException: + except requests.exceptions.RequestException as e: app.logger.error(f"Error contacting transaction-service: {e}") return jsonify({"error": "Transaction service unavailable"}), 503 diff --git a/financial-tracker/docker-compose.yml b/financial-tracker/docker-compose.yml index 66cca04e3a3dec9920d14bbf7260eb7a602de0b3..170cac38c54564887212b06d74fc241d9fede4ad 100644 --- a/financial-tracker/docker-compose.yml +++ b/financial-tracker/docker-compose.yml @@ -28,7 +28,8 @@ services: - DATABASE_URL=postgresql://user:password@user-db:5432/user_db - JWT_SECRET_KEY=3b5e41af18179f530c5881a5191e15f0ab35eed2fefdc068fda254eed3fb1ecb depends_on: - - user-db + user-db: + condition: service_healthy networks: - app-network healthcheck: @@ -85,16 +86,23 @@ services: notification-service: build: - context: . - dockerfile: notification-service/Dockerfile + context: ./notification-service ports: - - "5005:5005" + - "5005:5005" # Combined HTTP and WebSocket port environment: - DATABASE_URL=postgresql://user:password@notification-db:5432/notification_db + - JWT_SECRET_KEY=3b5e41af18179f530c5881a5191e15f0ab35eed2fefdc068fda254eed3fb1ecb + - EMAIL_USER=your_email_user + - EMAIL_PASS=your_email_password depends_on: - - notification-db + notification-db: + condition: service_healthy networks: - app-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5005/health"] + interval: 30s + retries: 3 frontend: build: @@ -120,6 +128,11 @@ services: - user_db_data:/var/lib/postgresql/data networks: - app-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d user_db"] + interval: 5s + timeout: 5s + retries: 5 transaction-db: image: postgres:16 @@ -176,6 +189,11 @@ services: - notification_db_data:/var/lib/postgresql/data networks: - app-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d notification_db"] + interval: 5s + timeout: 5s + retries: 5 volumes: user_db_data: diff --git a/financial-tracker/frontend/.env b/financial-tracker/frontend/.env new file mode 100644 index 0000000000000000000000000000000000000000..0519ecba6ea913e21689ec692e81e9e4973fbf73 --- /dev/null +++ b/financial-tracker/frontend/.env @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/financial-tracker/frontend/package.json b/financial-tracker/frontend/package.json index ffb0bc3b18555309b54a760b824218b8097ef72c..cec9ed04094662c6dee0d68ef3009c791bd815df 100644 --- a/financial-tracker/frontend/package.json +++ b/financial-tracker/frontend/package.json @@ -9,10 +9,13 @@ "@testing-library/user-event": "^13.5.0", "axios": "^1.8.4", "chart.js": "^4.4.8", + "bootstrap": "^5.3.2", "formik": "^2.4.6", "jwt-decode": "^4.0.0", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", + "react-bootstrap": "^2.9.0", + "react-bootstrap-icons": "^1.11.3", "react-dom": "^19.0.0", "react-icons": "^5.5.0", "react-paginate": "^8.3.0", diff --git a/financial-tracker/frontend/src/App.js b/financial-tracker/frontend/src/App.js index 6dbc470864737ab6f2a9049b2928793c09670f6c..81be08f0daf3901256220c9c9b140b6979d57454 100644 --- a/financial-tracker/frontend/src/App.js +++ b/financial-tracker/frontend/src/App.js @@ -6,11 +6,9 @@ import Register from "./pages/Register"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Income from "./pages/Income"; - - - import Expenses from "./pages/Expenses"; import Budget from "./pages/Budget"; +import Profile from "./pages/Profile"; function App() { const [token, setToken] = useState(localStorage.getItem("token")); @@ -18,6 +16,7 @@ function App() { useEffect(() => { const handleStorageChange = () => { setToken(localStorage.getItem("token")); + setToken(localStorage.getItem("token")); }; window.addEventListener("storage", handleStorageChange); @@ -26,6 +25,11 @@ function App() { }; }, []); + const handleLogout = () => { + localStorage.removeItem("token"); + setToken(null); + }; + return ( <Router> <div className="App"> @@ -38,9 +42,10 @@ function App() { {token ? ( <> <li><Link to="/dashboard">Dashboard</Link></li> - <li><Link to="/income">Income</Link></li> {/* 👈 Add this line */} + <li><Link to="/income">Income</Link></li> <li><Link to="/expenses">Expenses</Link></li> - <li><Link to="/budget">Budget</Link></li> + <li><Link to="/budget">Budget</Link></li> + <li><Link to="/profile">Profile</Link></li> <li> <Link to="/" onClick={() => { localStorage.removeItem("token"); setToken(null); }}> Logout @@ -49,8 +54,8 @@ function App() { </> ) : ( <> - <li><Link to="/login">Login</Link></li> - <li><Link to="/register">Register</Link></li> + <li><Link as={Link} to="/login">Login</Link></li> + <li><Link as={Link} to="/register">Register</Link></li> </> )} </ul> @@ -63,7 +68,8 @@ function App() { <Route path="/dashboard" element={<Dashboard setToken={setToken} />} /> <Route path="/income" element={<Income />} /> <Route path="/expenses" element={<Expenses />} /> - <Route path="/budget" element={<Budget />} /> + <Route path="/budget" element={<Budget />} /> + <Route path="/profile" element={<Profile setToken={setToken} />} /> </Routes> </div> </Router> diff --git a/financial-tracker/frontend/src/pages/Login.js b/financial-tracker/frontend/src/pages/Login.js index 070f63fd480bafa04a80383eaa39391a742fe95b..fc591c89c6c5dfe97eb76367ef5ca50a8efb6529 100644 --- a/financial-tracker/frontend/src/pages/Login.js +++ b/financial-tracker/frontend/src/pages/Login.js @@ -19,6 +19,9 @@ const Login = ({ setToken }) => { }); localStorage.setItem("token", response.data.access_token); + // Decode the JWT token to get the user ID + const tokenPayload = JSON.parse(atob(response.data.access_token.split('.')[1])); + localStorage.setItem("userId", tokenPayload.sub); setToken(response.data.access_token); // Update state immediately navigate("/dashboard"); } catch (err) { diff --git a/financial-tracker/frontend/src/pages/Profile.js b/financial-tracker/frontend/src/pages/Profile.js new file mode 100644 index 0000000000000000000000000000000000000000..274018211a38c4ffc45bc18492a25ff3d570d352 --- /dev/null +++ b/financial-tracker/frontend/src/pages/Profile.js @@ -0,0 +1,329 @@ +import React, { useState, useEffect } from 'react'; +import axios from "axios"; +import { jwtDecode } from 'jwt-decode'; +import { Formik, Form, Field, ErrorMessage } from "formik"; +import * as Yup from "yup"; +import '../styles/Profile.css'; + + +const Profile = () => { + const [profile, setProfile] = useState({ + first_name: '', + last_name: '', + email: '', + preferences: { + currency: '', + timezone: '' + } + }); + const [currencies, setCurrencies] = useState([]); + const [timezones, setTimezones] = useState([]); + const [successMessage, setSuccessMessage] = useState(''); + const [loading, setLoading] = useState(false); + const [isSessionValid, setIsSessionValid] = useState(true); + + // Define validation schema using Yup + const profileValidationSchema = Yup.object().shape({ + first_name: Yup.string() + .min(2, "First name must be at least 2 characters") + .max(50, "First name is too long") + .required("First name is required"), + last_name: Yup.string() + .min(2, "Last name must be at least 2 characters") + .max(50, "Last name is too long") + .required("Last name is required"), + email: Yup.string() + .email("Invalid email format") + .required("Email is required"), + currency: Yup.string() + .required("Currency selection is required"), + timezone: Yup.string() + .required("Timezone selection is required") + }); + const passwordValidationSchema = Yup.object().shape({ + currentPassword: Yup.string() + .required("Current password is required"), + newPassword: Yup.string() + .min(8, "New password must be at least 8 characters") + .matches(/[A-Z]/, "New password must contain at least one uppercase letter") + .matches(/[a-z]/, "New password must contain at least one lowercase letter") + .matches(/\d/, "New password must contain at least one number") + .matches(/[@$!%*?&_]/, "New password must contain at least one special character (@$!%*?&_)") + .required("New password is required"), + confirmPassword: Yup.string() + .oneOf([Yup.ref('newPassword'), null], "Passwords must match") + .required("Please confirm your new password") + }); + + // Check if token exists in localStorage to determine if session is valid + useEffect(() => { + const token = localStorage.getItem("token"); + + if (token) { + setIsSessionValid(true); // Token exists, session is valid + } else { + setIsSessionValid(false); // No token, session invalid + alert("Your session has expired. Please log in again."); + window.location.href = "/login"; // Redirect to login page + } + }, []); + + // Only fetch data if session is valid + useEffect(() => { + if (isSessionValid) { + fetchProfile(); + fetchCurrencies(); + fetchTimezones(); + } + }, [isSessionValid]); + + const fetchProfile = async () => { + try { + const token = localStorage.getItem("token"); + + if (!token) { + console.error("No token found, user might not be logged in."); + return; + } + + const decoded = jwtDecode(token); + console.log("Decoded JWT:", decoded); + + // Use 'sub' as user_id + const user_id = decoded.sub; + if (!user_id) { + console.error("User ID is undefined. Check JWT structure."); + return; + } + + const response = await axios.get("http://localhost:8000/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }) + + const userData = response.data; + console.log("Fetched profile info:", userData); + console.log(userData.last_name); + + setProfile({ + first_name: userData.first_name || '', + last_name: userData.last_name || '', + email: userData.email || '', + preferences: { + currency: userData.preferences.currency || 'GBP', + timezone: userData.preferences.timezone || 'BST' + } + }); + } catch (error) { + console.error('Error fetching profile:', error); + alert(`Error fetching profile: ${error.response}`); + } + }; + + const fetchCurrencies = async () => { + try { + const response = await axios.get('http://localhost:8000/currencies', { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + setCurrencies(response.data.currencies); + } catch (error) { + console.error('Error fetching currencies:', error); + } + }; + + const fetchTimezones = async () => { + try { + const response = await axios.get('http://localhost:8000/timezones', { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + setTimezones(response.data.timezones); + } catch (error) { + console.error('Error fetching timezones:', error); + } + }; + + + const handleProfileUpdate = async (values, { setSubmitting }) => { + setLoading(true); + + try { + const response = await axios.put("http://localhost:8000/auth/update", { + first_name: values.first_name, + last_name: values.last_name, + email: values.email, + preferences: { + currency: values.currency, + timezone: values.timezone + }, + }, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}` + } + }); + + setSuccessMessage("Profile updated successfully. Refreshing profile page..."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + console.error('Error updating profile:', error); + alert(`Error updating profile: ${error.response ? error.response.data.error || error.response.data.message : error.message}`); + } finally { + setLoading(false); + setSubmitting(false); + } + }; + + const handlePasswordUpdate = async (values, { setSubmitting }) => { + setLoading(true); + + try { + const response = await axios.put("http://localhost:8000/auth/password", { + current_password: values.currentPassword, + new_password: values.newPassword + }, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}` + } + }); + + setSuccessMessage("Password updated successfully. Refreshing profile page..."); + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + console.error('Error updating password:', error); + console.log(error.response) + if (error.response) { + const { status, data } = error.response; + console.log(status) + if (status === 401) { + alert(data.error || "Current password is incorrect."); + } else { + alert(data.error || data.message || "An unexpected error occurred."); + } + } else { + alert("Network error or server did not respond."); + } + + } finally { + setLoading(false); + setSubmitting(false); + } + }; + + + return ( + <div className="profile-container"> + <h1 className="page-title">Profile</h1> + + {/* Display the success message */} + {successMessage && <div className="success-message">{successMessage}</div>} + + <div className="profile-layout"> + <div className="profile-card"> + <h2 className="card-title">Profile Information</h2> + <Formik + enableReinitialize={true} + initialValues={{ + first_name: profile.first_name, + last_name: profile.last_name, + email: profile.email, + currency: profile.preferences.currency, + timezone: profile.preferences.timezone, + }} + validationSchema={profileValidationSchema} + onSubmit={handleProfileUpdate} + > + {({ isSubmitting }) => ( + <Form> + <div className="form-group"> + <label>First Name</label> + <Field type="text" name="first_name" className="form-control" /> + <ErrorMessage name="first_name" component="div" className="error" /> + </div> + + <div className="form-group"> + <label>Last Name</label> + <Field type="text" name="last_name" className="form-control" /> + <ErrorMessage name="last_name" component="div" className="error" /> + </div> + + <div className="form-group"> + <label>Email</label> + <Field type="email" name="email" className="form-control" /> + <ErrorMessage name="email" component="div" className="error" /> + </div> + + <div className="form-group"> + <label>Currency</label> + <Field as="select" name="currency" className="form-control"> + {currencies.map(currency => ( + <option key={currency} value={currency}>{currency}</option> + ))} + </Field> + <ErrorMessage name="currency" component="div" className="error" /> + </div> + + <div className="form-group"> + <label>Timezone</label> + <Field as="select" name="timezone" className="form-control"> + {timezones.map(timezone => ( + <option key={timezone} value={timezone}>{timezone}</option> + ))} + </Field> + <ErrorMessage name="timezone" component="div" className="error" /> + </div> + + <button type="submit" className="btn btn-primary w-100" disabled={isSubmitting || loading}> + {loading ? 'Updating...' : 'Update Profile'} + </button> + </Form> + )} + </Formik> + </div> + + <div className="profile-card"> + <h2 className="card-title">Change Password</h2> + <Formik + initialValues={{ + currentPassword: '', + newPassword: '', + confirmPassword: '' + }} + validationSchema={passwordValidationSchema} + onSubmit={handlePasswordUpdate} + > + {({ isSubmitting }) => ( + <Form> + <div className="form-group"> + <label>Current Password</label> + <Field type="password" name="currentPassword" className="form-control" /> + <ErrorMessage name="currentPassword" component="div" className="error" /> + </div> + + <div className="form-group"> + <label>New Password</label> + <Field type="password" name="newPassword" className="form-control" /> + <ErrorMessage name="newPassword" component="div" className="error" /> + </div> + + <div className="form-group"> + <label>Confirm New Password</label> + <Field type="password" name="confirmPassword" className="form-control" /> + <ErrorMessage name="confirmPassword" component="div" className="error" /> + </div> + + <button type="submit" className="btn btn-primary w-100" disabled={isSubmitting || loading}> + {loading ? 'Updating...' : 'Update Password'} + </button> + </Form> + )} + </Formik> + </div> + </div> + </div> + ); +}; + +export default Profile; \ No newline at end of file diff --git a/financial-tracker/frontend/src/pages/Register.js b/financial-tracker/frontend/src/pages/Register.js index 9c46a58e966eada3295331b2f481b930a32fe16f..21d1d49aa0956b04cf624a909639ab3e9f3da0fe 100644 --- a/financial-tracker/frontend/src/pages/Register.js +++ b/financial-tracker/frontend/src/pages/Register.js @@ -12,8 +12,6 @@ const Register = () => { const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - - // Define validation schema using Yup const validationSchema = Yup.object().shape({ firstName: Yup.string() @@ -41,7 +39,7 @@ const Register = () => { {/* Register Form */} <div className="register-container"> <h2>Register</h2> - + <Formik initialValues={{ firstName: "", lastName: "", email: "", password: "" }} validationSchema={validationSchema} @@ -66,89 +64,89 @@ const Register = () => { {status?.error && <p className="error-message">{status.error}</p>} {status?.success && <p className="success-message">{status.success}</p>} - + <div className="form-group"> - <label htmlFor="firstName"> - First Name <span className="required-asterisk">*</span> - </label> + <label htmlFor="firstName"> + First Name <span className="required-asterisk">*</span> + </label> <Field type="text" name="firstName" className="form-control" /> <ErrorMessage name="firstName" component="div" className="error" /> </div> <div className="form-group"> - <label htmlFor="lastName"> - Last Name <span className="required-asterisk">*</span> - </label> + <label htmlFor="lastName"> + Last Name <span className="required-asterisk">*</span> + </label> <Field type="text" name="lastName" className="form-control" /> <ErrorMessage name="lastName" component="div" className="error" /> </div> <div className="form-group"> - <label htmlFor="email"> - Email <span className="required-asterisk">*</span> - </label> + <label htmlFor="email"> + Email <span className="required-asterisk">*</span> + </label> <Field type="email" name="email" className="form-control" /> <ErrorMessage name="email" component="div" className="error" /> </div> - + <div className="form-group"> <label htmlFor="password"> Password <span className="required-asterisk">*</span> </label> - <Field name="password"> - {({ field, form }) => { - // Update local password state every time the field changes - const handlePasswordChange = (e) => { - setPassword(e.target.value); - form.setFieldValue("password", e.target.value); // Keep Formik in sync - }; - - return ( - <> - <div className="password-wrapper"> - <input - {...field} - type={showPassword ? "text" : "password"} - className="form-control" - onChange={handlePasswordChange} - placeholder="Enter your password" - /> - <span - className="toggle-password" - onClick={() => setShowPassword((prev) => !prev)} - title={showPassword ? "Hide password" : "Show password"} - > - {showPassword ? <FaEyeSlash /> : <FaEye />} - </span> - - - </div> - - {/* Dynamic password feedback */} - <ul className="password-rules"> - <li className={password.length >= 8 ? "valid" : "invalid"}> - {password.length >= 8 ? "✔" : "✖"} At least 8 characters - </li> - <li className={/[A-Z]/.test(password) ? "valid" : "invalid"}> - {/[A-Z]/.test(password) ? "✔" : "✖"} One uppercase letter - </li> - <li className={/[a-z]/.test(password) ? "valid" : "invalid"}> - {/[a-z]/.test(password) ? "✔" : "✖"} One lowercase letter - </li> - <li className={/\d/.test(password) ? "valid" : "invalid"}> - {/\d/.test(password) ? "✔" : "✖"} One number - </li> - <li className={/[@$!%*?&_]/.test(password) ? "valid" : "invalid"}> - {/[@$!%*?&_]/.test(password) ? "✔" : "✖"} One special character - </li> - </ul> - </> - ); - }} - </Field> - </div> - - + <Field name="password"> + {({ field, form }) => { + // Update local password state every time the field changes + const handlePasswordChange = (e) => { + setPassword(e.target.value); + form.setFieldValue("password", e.target.value); // Keep Formik in sync + }; + + return ( + <> + <div className="password-wrapper"> + <input + {...field} + type={showPassword ? "text" : "password"} + className="form-control" + onChange={handlePasswordChange} + placeholder="Enter your password" + /> + <span + className="toggle-password" + onClick={() => setShowPassword((prev) => !prev)} + title={showPassword ? "Hide password" : "Show password"} + > + {showPassword ? <FaEyeSlash /> : <FaEye />} + </span> + + + </div> + + {/* Dynamic password feedback */} + <ul className="password-rules"> + <li className={password.length >= 8 ? "valid" : "invalid"}> + {password.length >= 8 ? "✔" : "✖"} At least 8 characters + </li> + <li className={/[A-Z]/.test(password) ? "valid" : "invalid"}> + {/[A-Z]/.test(password) ? "✔" : "✖"} One uppercase letter + </li> + <li className={/[a-z]/.test(password) ? "valid" : "invalid"}> + {/[a-z]/.test(password) ? "✔" : "✖"} One lowercase letter + </li> + <li className={/\d/.test(password) ? "valid" : "invalid"}> + {/\d/.test(password) ? "✔" : "✖"} One number + </li> + <li className={/[@$!%*?&_]/.test(password) ? "valid" : "invalid"}> + {/[@$!%*?&_]/.test(password) ? "✔" : "✖"} One special character + </li> + </ul> + </> + ); + }} + </Field> + </div> + + <button type="submit" className="btn" disabled={isSubmitting}> {isSubmitting ? "Registering..." : "Register"} </button> diff --git a/financial-tracker/frontend/src/styles/App.css b/financial-tracker/frontend/src/styles/App.css index 74b5e053450a48a6bdb4d71aad648e7af821975c..2a048894af5de192ea133c2d4aa0299191b0a2db 100644 --- a/financial-tracker/frontend/src/styles/App.css +++ b/financial-tracker/frontend/src/styles/App.css @@ -1,18 +1,15 @@ .App { text-align: center; } - .App-logo { height: 40vmin; pointer-events: none; } - @media (prefers-reduced-motion: no-preference) { .App-logo { animation: App-logo-spin infinite 20s linear; } } - .App-header { background-color: #282c34; min-height: 100vh; @@ -23,11 +20,9 @@ font-size: calc(10px + 2vmin); color: white; } - .App-link { color: #61dafb; } - @keyframes App-logo-spin { from { transform: rotate(0deg); @@ -35,4 +30,4 @@ to { transform: rotate(360deg); } -} +} \ No newline at end of file diff --git a/financial-tracker/frontend/src/styles/Profile.css b/financial-tracker/frontend/src/styles/Profile.css new file mode 100644 index 0000000000000000000000000000000000000000..ccac474c93c9aae5c419202e58cc8274e50755a8 --- /dev/null +++ b/financial-tracker/frontend/src/styles/Profile.css @@ -0,0 +1,102 @@ +/* Profile.css */ +h1 { + font-size: 2.5em; + text-align: center; +} + +.profile-container { + margin: 0 auto; + border-radius: 8px; +} + +.profile-layout { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 2rem; +} + +.profile-card { + background-color: #f7f7f7; + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 500px; +} + +.profile-info, .change-password { + flex: 1; + min-width: 300px; +} + +.form-group { + margin-bottom: 15px; +} + +.profile-container h2 { + text-align: center; +} + +.card-title label { + display: block; +} + +label { + margin-bottom: 5px; + font-weight: bold; +} + +.profile-card input, +.profile-card select { + width: 100%; + padding: 8px; + margin-top: 5px; + border-radius: 5px; + border: 1px solid #ddd; +} + +.profile-card input[type="text"], +.profile-card input[type="email"], +.profile-card input[type="password"], +.profile-card select { + font-size: 16px; +} + +button { + width: 100%; + padding: 10px; + background-color: #007bff; + color: white; + font-size: 16px; + border: none; + border-radius: 5px; +} + +button:hover { + background-color: #0056b3; + cursor: pointer; +} + +/* Success Message */ +.success-message { + background-color: #4CAF50; + border-radius: 5px; + color: white; + margin: 10px auto; + margin-top: 10px; + padding: 10px; + width: 80%; +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + .profile-container { + width: 90%; + } + + .profile-layout { + flex-direction: column; + gap: 20px; + } +} diff --git a/financial-tracker/notification-service/.DS_Store b/financial-tracker/notification-service/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..65dd82e3eb29c204fa0bcd2edece958c325989b8 Binary files /dev/null and b/financial-tracker/notification-service/.DS_Store differ diff --git a/financial-tracker/notification-service/.env b/financial-tracker/notification-service/.env new file mode 100644 index 0000000000000000000000000000000000000000..9361d60bd3551bb35d6ba377df05f41379363512 --- /dev/null +++ b/financial-tracker/notification-service/.env @@ -0,0 +1,2 @@ +MONGO_URI=mongodb://mongo:27017/notifications +JWT_SECRET=your_jwt_secret diff --git a/financial-tracker/notification-service/Dockerfile b/financial-tracker/notification-service/Dockerfile index 1dd6cfae18c3c41e3c824d26b25492894b552a69..cece852b7a973bc2c6055207f9c70c29f5e79088 100644 --- a/financial-tracker/notification-service/Dockerfile +++ b/financial-tracker/notification-service/Dockerfile @@ -1,18 +1,14 @@ -FROM python:3.12 +FROM node:20-alpine WORKDIR /app -# Set default database URL -ENV DATABASE_URL=postgresql://user:password@notification-db:5432/notification_db +COPY package*.json ./ -# Copy requirements.txt from root and install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN npm install -# Copy only the current service's code -COPY ./notification-service/app /app +COPY . . -# Expose the service port EXPOSE 5005 +EXPOSE 8080 -CMD ["python", "app.py"] +CMD ["npm", "start"] diff --git a/financial-tracker/notification-service/app/controllers/notificationController.js b/financial-tracker/notification-service/app/controllers/notificationController.js new file mode 100644 index 0000000000000000000000000000000000000000..8ef39d141a8a1133e55e27c0f9fc59b2a9960534 --- /dev/null +++ b/financial-tracker/notification-service/app/controllers/notificationController.js @@ -0,0 +1,38 @@ +const Notification = require("../models/Notification"); + +exports.createNotification = async (req, res) => { + try { + const notification = new Notification(req.body); + await notification.save(); + res.status(201).json(notification); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +exports.getNotifications = async (req, res) => { + try { + const notifications = await Notification.find({ userId: req.params.userId }); + res.json(notifications); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +exports.markAsRead = async (req, res) => { + try { + await Notification.findByIdAndUpdate(req.params.id, { read: true }); + res.status(200).json({ message: "Notification marked as read" }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +exports.deleteNotification = async (req, res) => { + try { + await Notification.findByIdAndDelete(req.params.id); + res.status(200).json({ message: "Notification deleted" }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; diff --git a/financial-tracker/notification-service/app/middleware/auth.js b/financial-tracker/notification-service/app/middleware/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..4687a20f2dad73ae0a3404cd67cdc311490a41bf --- /dev/null +++ b/financial-tracker/notification-service/app/middleware/auth.js @@ -0,0 +1,14 @@ +const jwt = require("jsonwebtoken"); + +module.exports = (req, res, next) => { + const token = req.header("Authorization")?.replace("Bearer ", ""); + if (!token) return res.status(401).json({ error: "Access denied. No token provided." }); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded; + next(); + } catch (err) { + res.status(400).json({ error: "Invalid token." }); + } +}; \ No newline at end of file diff --git a/financial-tracker/notification-service/app/models/Notification.js b/financial-tracker/notification-service/app/models/Notification.js new file mode 100644 index 0000000000000000000000000000000000000000..59bf51ad33e849bd7f565d7545110f4af00ac092 --- /dev/null +++ b/financial-tracker/notification-service/app/models/Notification.js @@ -0,0 +1,11 @@ +const mongoose = require("mongoose"); + +const NotificationSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + message: { type: String, required: true }, + type: { type: String, enum: ["budget", "transaction", "bill", "general"], default: "general" }, + date: { type: Date, default: Date.now }, + read: { type: Boolean, default: false }, +}); + +module.exports = mongoose.model("Notification", NotificationSchema); \ No newline at end of file diff --git a/financial-tracker/notification-service/app/routes/notifications.js b/financial-tracker/notification-service/app/routes/notifications.js new file mode 100644 index 0000000000000000000000000000000000000000..cf13216926ced3d57ba1889bbef8a45c9924be85 --- /dev/null +++ b/financial-tracker/notification-service/app/routes/notifications.js @@ -0,0 +1,11 @@ +const express = require("express"); +const router = express.Router(); +const controller = require("../controllers/notificationController"); +const auth = require("../middleware/auth"); + +router.post("/", auth, controller.createNotification); +router.get("/:userId", auth, controller.getNotifications); +router.patch("/read/:id", auth, controller.markAsRead); +router.delete("/:id", auth, controller.deleteNotification); + +module.exports = router; diff --git a/financial-tracker/notification-service/package.json b/financial-tracker/notification-service/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8f5539a03b6c9ee50bfe8ba05487407bc76daf03 --- /dev/null +++ b/financial-tracker/notification-service/package.json @@ -0,0 +1,22 @@ +{ + "name": "notification-service", + "version": "1.0.0", + "description": "Notification service for Financial Tracker", + "main": "app.js", + "scripts": { + "start": "node app.js", + "dev": "nodemon app.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "nodemailer": "^6.9.7", + "pg": "^8.11.3", + "ws": "^8.14.2", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} \ No newline at end of file diff --git a/financial-tracker/notification-service/routes/notifications.js b/financial-tracker/notification-service/routes/notifications.js new file mode 100644 index 0000000000000000000000000000000000000000..f376597018ae012baa982bb3a789fd90fff815ce --- /dev/null +++ b/financial-tracker/notification-service/routes/notifications.js @@ -0,0 +1,199 @@ +const express = require('express'); +const router = express.Router(); +const { Pool } = require('pg'); +const jwt = require('jsonwebtoken'); + +// PostgreSQL connection +const pool = new Pool({ + user: "user", + host: "notification-db", + database: "notification_db", + password: "password", + port: 5432, +}); + +// Get all notifications +router.get('/', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM notifications ORDER BY created_at DESC'); + res.json(result.rows); + } catch (error) { + console.error('Get all notifications error:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +// Create notification +router.post('/', async (req, res) => { + try { + const { user_id, title, message, type, priority = 'normal', metadata = {} } = req.body; + + // Check user's notification preferences + const prefsResult = await pool.query( + 'SELECT * FROM notification_preferences WHERE user_id = $1', + [user_id] + ); + + const prefs = prefsResult.rows[0] || { + budget_alerts: true, + transaction_alerts: true, + bill_reminders: true, + email_notifications: true, + push_notifications: true + }; + + // Create notification in database + const result = await pool.query( + `INSERT INTO notifications + (user_id, title, message, type, priority, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [user_id, title, message, type, priority, metadata] + ); + + const notification = result.rows[0]; + res.status(201).json(notification); + } catch (error) { + console.error('Create notification error:', error); + res.status(500).json({ error: 'Failed to create notification' }); + } +}); + +// Get user's notifications +router.get('/:userId', async (req, res) => { + try { + const { userId } = req.params; + const { unread_only } = req.query; + + let query = 'SELECT * FROM notifications WHERE user_id = $1'; + if (unread_only === 'true') { + query += ' AND read = FALSE'; + } + query += ' ORDER BY created_at DESC'; + + const result = await pool.query(query, [userId]); + res.json(result.rows); + } catch (error) { + console.error('Get notifications error:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +// Mark notification as read +router.put('/:id/read', async (req, res) => { + try { + const { id } = req.params; + const result = await pool.query( + 'UPDATE notifications SET read = TRUE WHERE id = $1 RETURNING *', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Notification not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Mark as read error:', error); + res.status(500).json({ error: 'Failed to update notification' }); + } +}); + +// Delete notification +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const result = await pool.query( + 'DELETE FROM notifications WHERE id = $1 RETURNING *', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Notification not found' }); + } + + res.json({ message: 'Notification deleted successfully' }); + } catch (error) { + console.error('Delete notification error:', error); + res.status(500).json({ error: 'Failed to delete notification' }); + } +}); + +// Get notification preferences +router.get('/preferences/:userId', async (req, res) => { + try { + const { userId } = req.params; + const result = await pool.query( + 'SELECT * FROM notification_preferences WHERE user_id = $1', + [userId] + ); + + if (result.rows.length === 0) { + // Return default preferences if none set + return res.json({ + user_id: userId, + budget_alerts: true, + transaction_alerts: true, + bill_reminders: true, + email_notifications: true, + push_notifications: true + }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Get preferences error:', error); + res.status(500).json({ error: 'Failed to fetch notification preferences' }); + } +}); + +// Update notification preferences +router.put('/preferences/:userId', async (req, res) => { + try { + const { userId } = req.params; + const { + budget_alerts, + transaction_alerts, + bill_reminders, + email_notifications, + push_notifications + } = req.body; + + const result = await pool.query( + `INSERT INTO notification_preferences + (user_id, budget_alerts, transaction_alerts, bill_reminders, email_notifications, push_notifications) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id) + DO UPDATE SET + budget_alerts = $2, + transaction_alerts = $3, + bill_reminders = $4, + email_notifications = $5, + push_notifications = $6 + RETURNING *`, + [userId, budget_alerts, transaction_alerts, bill_reminders, email_notifications, push_notifications] + ); + + res.json(result.rows[0]); + } catch (error) { + console.error('Update preferences error:', error); + res.status(500).json({ error: 'Failed to update notification preferences' }); + } +}); + +// Mark all notifications as read +router.put('/read-all/:userId', async (req, res) => { + try { + const { userId } = req.params; + await pool.query( + 'UPDATE notifications SET read = TRUE WHERE user_id = $1', + [userId] + ); + res.json({ message: 'All notifications marked as read' }); + } catch (error) { + console.error('Mark all as read error:', error); + res.status(500).json({ error: 'Failed to update notifications' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/financial-tracker/package-lock.json b/financial-tracker/package-lock.json deleted file mode 100644 index bc227805b354553200e0a7bc37123691ce8c2f08..0000000000000000000000000000000000000000 --- a/financial-tracker/package-lock.json +++ /dev/null @@ -1,378 +0,0 @@ -{ - "name": "financial-tracker", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "bootstrap": "^5.3.5", - "react-bootstrap": "^2.10.9" - } - }, - "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.7", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", - "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@restart/hooks": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", - "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", - "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@popperjs/core": "^2.11.8", - "@react-aria/ssr": "^3.5.0", - "@restart/hooks": "^0.5.0", - "@types/warning": "^3.0.3", - "dequal": "^2.0.3", - "dom-helpers": "^5.2.0", - "uncontrollable": "^8.0.4", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - } - }, - "node_modules/@restart/ui/node_modules/@restart/hooks": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", - "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui/node_modules/uncontrollable": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", - "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.14.0" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", - "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/warning": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", - "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", - "license": "MIT" - }, - "node_modules/bootstrap": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", - "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types-extra": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", - "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", - "license": "MIT", - "dependencies": { - "react-is": "^16.3.2", - "warning": "^4.0.0" - }, - "peerDependencies": { - "react": ">=0.14.0" - } - }, - "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-bootstrap": { - "version": "2.10.9", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", - "integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7", - "@restart/hooks": "^0.4.9", - "@restart/ui": "^1.9.4", - "@types/prop-types": "^15.7.12", - "@types/react-transition-group": "^4.4.6", - "classnames": "^2.3.2", - "dom-helpers": "^5.2.1", - "invariant": "^2.2.4", - "prop-types": "^15.8.1", - "prop-types-extra": "^1.1.0", - "react-transition-group": "^4.4.5", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "@types/react": ">=16.14.8", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT", - "peer": true - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/uncontrollable": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", - "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.6.3", - "@types/react": ">=16.9.11", - "invariant": "^2.2.4", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - } - } -} diff --git a/financial-tracker/package.json b/financial-tracker/package.json deleted file mode 100644 index 72a87f2d71eefe128a39a311e2e4f714ecdd9faa..0000000000000000000000000000000000000000 --- a/financial-tracker/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dependencies": { - "bootstrap": "^5.3.5", - "react-bootstrap": "^2.10.9" - } -} diff --git a/financial-tracker/transaction-service/requirements.txt b/financial-tracker/transaction-service/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5f1fd100f7cb7cccbefae104a4436fd8d80d1a93 --- /dev/null +++ b/financial-tracker/transaction-service/requirements.txt @@ -0,0 +1,15 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-JWT-Extended==4.6.0 +Flask-CORS==4.0.0 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +requests==2.31.0 +SQLAlchemy==2.0.23 +Werkzeug==3.0.1 +python-dateutil==2.8.2 +pytest==7.4.3 +pytest-cov==4.1.0 +black==23.11.0 +flake8==6.1.0 +mypy==1.7.1 \ No newline at end of file diff --git a/financial-tracker/user-service/app/app.py b/financial-tracker/user-service/app/app.py index 09c055f4f5f7736c1da89f93569e6d6bc0016076..a257f52d8d06b939ef1883af9696887ecf41c1ac 100644 --- a/financial-tracker/user-service/app/app.py +++ b/financial-tracker/user-service/app/app.py @@ -6,10 +6,10 @@ from flask_jwt_extended import ( ) from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash +import enum import logging import os - # Initialise Flask app app = Flask(__name__) app.debug = True @@ -33,6 +33,92 @@ app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) jwt = JWTManager(app) db = SQLAlchemy(app) +# Define Enums for User model +class CurrencyEnum(enum.Enum): + USD = "USD" + EUR = "EUR" + GBP = "GBP" + JPY = "JPY" + AUD = "AUD" + CAD = "CAD" + CHF = "CHF" + CNY = "CNY" + INR = "INR" + NZD = "NZD" + MXN = "MXN" + SGD = "SGD" + HKD = "HKD" + SEK = "SEK" + NOK = "NOK" + DKK = "DKK" + KRW = "KRW" + BRL = "BRL" + BWP = "BWP" + BDT = "BDT" + BGN = "BGN" + BHD = "BHD" + BIF = "BIF" + BOB = "BOB" + CVE = "CVE" + CZK = "CZK" + DOP = "DOP" + EGP = "EGP" + ETB = "ETB" + FJD = "FJD" + GHS = "GHS" + GIP = "GIP" + GMD = "GMD" + GNF = "GNF" + GTQ = "GTQ" + HUF = "HUF" + IDR = "IDR" + ISK = "ISK" + JOD = "JOD" + KES = "KES" + KWD = "KWD" + LAK = "LAK" + LKR = "LKR" + MAD = "MAD" + MGA = "MGA" + MWK = "MWK" + MYR = "MYR" + MZN = "MZN" + NGN = "NGN" + NPR = "NPR" + OMR = "OMR" + PEN = "PEN" + PHP = "PHP" + PKR = "PKR" + PLN = "PLN" + PYG = "PYG" + QAR = "QAR" + RON = "RON" + RWF = "RWF" + SAR = "SAR" + SLE = "SLE" + SRD = "SRD" + THB = "THB" + TND = "TND" + TRY = "TRY" + TWD = "TWD" + TZS = "TZS" + UGX = "UGX" + VND = "VND" + XAF = "XAF" + XCD = "XCD" + XOF = "XOF" + ZAR = "ZAR" + ZMW = "ZMW" + +class TimezoneEnum(enum.Enum): + UTC = "UTC" + GMT = "GMT" + BST = "BST" + EST = "Eastern Standard Time (EST)" + EDT = "Eastern Daylight Time (EDT)" + CST = "Central Standard Time (CST)" + CDT = "Central Daylight Time (CDT)" + # DATABASE MODELS class User(db.Model): """User model for storing user information.""" @@ -41,6 +127,8 @@ class User(db.Model): last_name = db.Column(db.String(50), nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(256), nullable=False) + currency = db.Column(db.Enum(CurrencyEnum), nullable=False, default='GBP') + timezone = db.Column(db.Enum(TimezoneEnum), nullable=False, default='GMT') def set_password(self, password): """Hash and store the user's password.""" @@ -127,14 +215,12 @@ def get_user(): user_id = get_jwt_identity() app.logger.info(f"Extracted user_id from JWT: {user_id}") - # Convert user_id to integer try: user_id = int(user_id) except ValueError: app.logger.error("Invalid token payload: user_id is not an integer") return jsonify({"error": "Invalid token payload"}), 401 - # Fetch user from DB user = User.query.get(user_id) app.logger.info(f"Fetched user from DB: {user}") @@ -146,7 +232,11 @@ def get_user(): "id": user.id, "first_name": user.first_name, "last_name": user.last_name, - "email": user.email + "email": user.email, + "preferences": { + "currency": user.currency.value, + "timezone": user.timezone.value + } }), 200 except Exception as e: @@ -165,6 +255,117 @@ def logout(): return jsonify({"message": "Successfully logged out!"}), 200 +@app.route('/auth/password', methods=['PUT', 'OPTIONS']) +@jwt_required() +def update_password(): + """Update user password.""" + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.get_json() + current_password = data.get('current_password') + new_password = data.get('new_password') + + if not current_password or not new_password: + return jsonify({"error": "Current password and new password are required"}), 400 + + user_id = get_jwt_identity() + user = User.query.get(int(user_id)) + + if not user: + return jsonify({"error": "User not found"}), 404 + + if not user.check_password(current_password): + return jsonify({"error": "Invalid credentials"}), 401 + + user.set_password(new_password) + db.session.commit() + + return jsonify({"message": "Password updated successfully"}), 200 + + except Exception as e: + app.logger.error(f"Error updating password: {str(e)}") + return jsonify({"error": "Failed to update password"}), 500 + + +@app.route('/auth/update', methods=['PUT', 'OPTIONS']) +@jwt_required() +def update_profile(): + """Update user profile.""" + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.get_json() + user_id = get_jwt_identity() + user = User.query.get(int(user_id)) + + if not user: + return jsonify({"error": "User not found"}), 404 + + # Update basic fields if provided + if 'first_name' in data: + user.first_name = data['first_name'] + if 'last_name' in data: + user.last_name = data['last_name'] + if 'email' in data: + # Check if email is already taken by another user + existing_user = User.query.filter_by(email=data['email']).first() + if existing_user and existing_user.id != user.id: + return jsonify({"error": "Email already exists"}), 400 + user.email = data['email'] + + # Update preferences if provided + if 'preferences' in data: + preferences = data['preferences'] + if 'currency' in preferences: + user.currency = preferences['currency'] + if 'timezone' in preferences: + user.timezone = preferences['timezone'] + + db.session.commit() + + return jsonify({ + "id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "preferences": { + "currency": user.currency.value, + "timezone": user.timezone.value + } + }), 200 + + except Exception as e: + app.logger.error(f"Error updating profile: {str(e)}") + return jsonify({"error": "Failed to update profile"}), 500 + + +@app.route('/currencies', methods=['GET']) +@jwt_required() +def get_currencies(): + """Retrieve supported currencies.""" + try: + currencies = [currency.value for currency in CurrencyEnum] + return jsonify({"currencies": currencies}), 200 + except Exception as e: + app.logger.error(f"Error retrieving currencies: {e}", exc_info=True) + return jsonify({"error": "An error occurred while fetching currencies"}), 500 + + +@app.route('/timezones', methods=['GET']) +@jwt_required() +def get_timezones(): + """Retrieve supported timezones.""" + try: + timezones = [timezone.value for timezone in TimezoneEnum] + return jsonify({"timezones": timezones}), 200 + except Exception as e: + app.logger.error(f"Error retrieving timezones: {e}", exc_info=True) + return jsonify({"error": "An error occurred while fetching timezones"}), 500 + + # HEALTH CHECK ROUTE @app.route('/health') def health(): @@ -173,8 +374,8 @@ def health(): return jsonify({"status": "User service is running"}), 200 -# INITIALIZATION & RUNNING APP -if __name__ == "__main__": +# INITIALISATION & RUNNING APP +if __name__ == "__main__": # Create all tables in the database (if they don't exist) with app.app_context(): db.create_all() # Creates the tables for all models diff --git a/financial-tracker/user-service/requirements.txt b/financial-tracker/user-service/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5d623d49e3224c9a7b1f6cee6d0ff8596b82beee --- /dev/null +++ b/financial-tracker/user-service/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.1.0 +Flask-JWT-Extended==4.7.1 +Flask-SQLAlchemy==3.1.1 +Flask-CORS==5.0.1 +psycopg2-binary==2.9.9 +python-dotenv==1.0.1 +pytz==2024.1 +Werkzeug>=3.1.0 \ No newline at end of file