From d9f79ef4e077eeafb7ecabbd9d7aec627eb8f1ab Mon Sep 17 00:00:00 2001 From: "Atiku, Juwon (UG - Comp Sci & Elec Eng)" <oa01351@surrey.ac.uk> Date: Mon, 14 Apr 2025 15:34:42 +0000 Subject: [PATCH] Implement User Profile Management Service Removed Bootstrap styling and fixed CSS styling Removed notification features Added Axios and JWT decode imports Used Formik to handle form state and validation Updated handleProfileUpdate and handlePasswordUpdate functions Modularised API request logic Added Axios calls for fetching currencies and timezones Implemented enum handling for currencies and timezones Integrated API gateway routes --- financial-tracker/api-gateway/app/app.py | 103 ++++- financial-tracker/docker-compose.yml | 28 +- financial-tracker/frontend/.env | 1 + financial-tracker/frontend/package.json | 3 + financial-tracker/frontend/src/App.js | 22 +- financial-tracker/frontend/src/pages/Login.js | 3 + .../frontend/src/pages/Profile.js | 329 +++++++++++++++ .../frontend/src/pages/Register.js | 134 +++---- financial-tracker/frontend/src/styles/App.css | 7 +- .../frontend/src/styles/Profile.css | 102 +++++ .../notification-service/.DS_Store | Bin 0 -> 6148 bytes financial-tracker/notification-service/.env | 2 + .../notification-service/Dockerfile | 16 +- .../app/controllers/notificationController.js | 38 ++ .../app/middleware/auth.js | 14 + .../app/models/Notification.js | 11 + .../app/routes/notifications.js | 11 + .../notification-service/package.json | 22 + .../routes/notifications.js | 199 +++++++++ financial-tracker/package-lock.json | 378 ------------------ financial-tracker/package.json | 6 - .../transaction-service/requirements.txt | 15 + financial-tracker/user-service/app/app.py | 213 +++++++++- .../user-service/requirements.txt | 8 + 24 files changed, 1173 insertions(+), 492 deletions(-) create mode 100644 financial-tracker/frontend/.env create mode 100644 financial-tracker/frontend/src/pages/Profile.js create mode 100644 financial-tracker/frontend/src/styles/Profile.css create mode 100644 financial-tracker/notification-service/.DS_Store create mode 100644 financial-tracker/notification-service/.env create mode 100644 financial-tracker/notification-service/app/controllers/notificationController.js create mode 100644 financial-tracker/notification-service/app/middleware/auth.js create mode 100644 financial-tracker/notification-service/app/models/Notification.js create mode 100644 financial-tracker/notification-service/app/routes/notifications.js create mode 100644 financial-tracker/notification-service/package.json create mode 100644 financial-tracker/notification-service/routes/notifications.js delete mode 100644 financial-tracker/package-lock.json delete mode 100644 financial-tracker/package.json create mode 100644 financial-tracker/transaction-service/requirements.txt create mode 100644 financial-tracker/user-service/requirements.txt diff --git a/financial-tracker/api-gateway/app/app.py b/financial-tracker/api-gateway/app/app.py index c3961c4..f259d78 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 66cca04..170cac3 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 0000000..0519ecb --- /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 ffb0bc3..cec9ed0 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 6dbc470..81be08f 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 070f63f..fc591c8 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 0000000..2740182 --- /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 9c46a58..21d1d49 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 74b5e05..2a04889 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 0000000..ccac474 --- /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 GIT binary patch literal 6148 zcmeHK!A=4(5S;?bA~E5h#^WYli7p8-CSF#=gICw+K@IN0svFiVfkh64WRLna-ux7Q zM`zk%MD(Z$W+s`wb~@8;UpH+B07PThsR2{~z(OU=m9Y3msGoFBa>i0X6f}lx2eAyW zMZ>Wy0t2*ndGMhJz2kUh{|>Nc`%Q^ji17+WgPz&tm!KC%gVb?eMJ``hS}s~et886) zcXH&VemWd9{Qd>CPNj^4Y26RbqHxrztR2cE^`j*0tAMBzqRZuZlyu~%A%{t4pkh7U zVU?^>tFkd3*PN>DOse*HuU@a(HD|XrnUt*ct)2aoRyV#+<fEot;2)`E)!-DKuwvHo z;PsPOCbwuI?-gi*gf?^`gg)Fr1TlnPSb0Xj&#YjRg&AN5n1O|3z?_g)dEvgCKbRR{ z27a3XIv;FQLf2rXQ5_vvs1yK^o{>tht{(r0HW?6IgPBIOpa>m`s6&NGF@(t-gbv4k z*Z5}|bvOt$Gmc|s7A8XxCiM`uatGmR<dPX+24)$^>t==S|D&Jt|JfjJF$2uNzhXcX znqISkOEP!s+~Vl2wNbB8NysnL_z{8?brqv8UB#QIO3-do1<^H_X+#SOKLivFTrdNF G%D_93SYT!V literal 0 HcmV?d00001 diff --git a/financial-tracker/notification-service/.env b/financial-tracker/notification-service/.env new file mode 100644 index 0000000..9361d60 --- /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 1dd6cfa..cece852 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 0000000..8ef39d1 --- /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 0000000..4687a20 --- /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 0000000..59bf51a --- /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 0000000..cf13216 --- /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 0000000..8f5539a --- /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 0000000..f376597 --- /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 bc22780..0000000 --- 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 72a87f2..0000000 --- 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 0000000..5f1fd10 --- /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 09c055f..a257f52 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 0000000..5d623d4 --- /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 -- GitLab