diff --git a/.gitignore b/.gitignore index b921615..7ab172d 100644 --- a/.gitignore +++ b/.gitignore @@ -120,4 +120,5 @@ app.*.symbols # Python virtual environment venv/ .venv/ - +#Environment variables +.env diff --git a/app.py b/app.py index 59a5a05..1f90fc3 100644 --- a/app.py +++ b/app.py @@ -12,12 +12,40 @@ from flask import Flask, render_template from routes.main_routes import main +from routes.auth_routes import auth, db +from datetime import timedelta +from authlib.integrations.flask_client import OAuth +from dotenv import load_dotenv +load_dotenv(dotenv_path=".env") +import os app = Flask(__name__) +#Session secret key +app.secret_key =os.getenv("SECRET_KEY") +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +#automatic logout after 7 days +app.permanent_session_lifetime = timedelta(days=7) + +#Initialize db +db.init_app(app) +#Initialize OAuth +oauth = OAuth(app) +google = oauth.register( + name='google', + client_id=os.getenv("GOOGLE_CLIENT_ID"), + client_secret=os.getenv("GOOGLE_CLIENT_SECRET"), + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile' + } +) +app.extensions['google_auth'] = google # Register all routes defined in the main Blueprint app.register_blueprint(main) - +app.register_blueprint(auth) # ---- Error handlers ---- @@ -32,12 +60,14 @@ def internal_server_error(error): """Render a friendly 500 page for unexpected server errors.""" return render_template("500.html"), 500 +auth-feature +with app.app_context(): + db.create_all() @app.errorhandler(405) def method_not_allowed(error): """Render a friendly 405 page when the wrong HTTP method is used.""" return render_template("405.html"), 405 - - +main if __name__ == "__main__": # debug=True is only for local development. # Never run with debug=True in a production deployment. diff --git a/instance/database.db b/instance/database.db new file mode 100644 index 0000000..9279cf7 Binary files /dev/null and b/instance/database.db differ diff --git a/routes/auth_routes.py b/routes/auth_routes.py new file mode 100644 index 0000000..e9cdadb --- /dev/null +++ b/routes/auth_routes.py @@ -0,0 +1,136 @@ +from flask import Blueprint, request, render_template, redirect, session, flash,current_app +from authlib.integrations.flask_client import OAuth +from flask_sqlalchemy import SQLAlchemy +import bcrypt + +# Create Blueprint +auth = Blueprint("auth", __name__) + +# Initialize Database +db = SQLAlchemy() + +# User Model +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(100), unique=True, nullable=False) + password = db.Column(db.String(200), nullable=False) + # Password Check Method + def check_password(self, password): + return bcrypt.checkpw( + password.encode("utf-8"), + self.password.encode("utf-8") + ) + +#Signup route +@auth.route('/signup', methods=['GET', 'POST']) +def signup(): + if request.method == 'POST': + name = request.form['name'] + email = request.form['email'] + password = request.form['password'] + # Check if user already exists + existing_user = User.query.filter_by(email=email).first() + if existing_user: + flash("User already exists.", "error") + return render_template('signup.html') + # Hash password + hashed_password = bcrypt.hashpw( + password.encode('utf-8'), + bcrypt.gensalt() + ).decode('utf-8') + # Create user + new_user = User( + name=name, + email=email, + password=hashed_password + ) + db.session.add(new_user) + db.session.commit() + flash("Signup successful! Please login.", "success") + return redirect('/login') + + return render_template('signup.html') + +#Login Route +@auth.route('/login', methods=['GET', 'POST']) +def login(): + + if request.method == 'POST': + email = request.form['email'] + password = request.form['password'] + user = User.query.filter_by(email=email).first() + # Validate user + if user and user.check_password(password): + # Store session + session['email'] = user.email + session.permanent=True + flash("Logged in successfully!", "success") + return redirect('/dashboard') + else: + flash("Invalid email or password.", "error") + return render_template('login.html') + + return render_template('login.html') + +#DashBoard Route +@auth.route('/dashboard') +def dashboard(): + # Check if logged in + if 'email' in session: + user = User.query.filter_by( + email=session['email'] + ).first() + return render_template( + 'dashboard.html', + user=user + ) + flash("Please login first.", "error") + return redirect('/login') + +#Logout Route +@auth.route('/logout') +def logout(): + + session.pop('email', None) + flash("Logged out successfully.", "success") + return redirect('/login') + +#Google Login Route +@auth.route('/login/google') +def login_google(): + google = current_app.extensions['google_auth'] + return google.authorize_redirect( + 'http://127.0.0.1:5000/login/google/authorized', + prompt="consent select_account"#ensure account selection during google login + ) + + +@auth.route('/login/google/authorized') +def google_authorized(): + google = current_app.extensions['google_auth'] + + token = google.authorize_access_token() + + user_info = token.get("userinfo") + if not user_info: + user_info = google.parse_id_token(token) + + email = user_info["email"] + name = user_info["name"] + + user = User.query.filter_by(email=email).first() + + if not user: + user = User( + name=name, + email=email, + password="google-user" + ) + db.session.add(user) + db.session.commit() + + session["email"] = email + + flash("Google login successful!", "success") + return redirect("/dashboard") \ No newline at end of file diff --git a/routes/main_routes.py b/routes/main_routes.py index 5cb3459..a2b04b2 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -3,7 +3,7 @@ # Each route is kept thin: it validates input, calls a utility function, # and returns a response. No business logic lives here. -from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort +from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort, redirect,session from utils.recommender import get_recommendations, validate_recommendation_inputs from utils.data_loader import find_project_by_id, get_project_stats @@ -42,6 +42,10 @@ def recommend(): interest (str) - Web | Data | Education | Automation | Games time (str) - Low | Medium | High """ + if 'email' not in session: #done to ensure that only authenticated users get to use website features + return jsonify({ + "error": "Please login to use DevPath." + }), 401 payload = request.get_json() if not payload: @@ -75,6 +79,8 @@ def recommend(): @main.route("/project/") def project_detail(project_id): """Render the full detail page for a single project.""" + if 'email' not in session: + return redirect('/login') project = find_project_by_id(project_id) if not project: abort(404) @@ -84,6 +90,10 @@ def project_detail(project_id): @main.route("/project//code") def view_code(project_id): """Return the starter code file contents as JSON for inline display.""" + if 'email' not in session: + return jsonify({ + "error": "Unauthorized" + }), 401 project = find_project_by_id(project_id) if not project: return jsonify({"error": "Project not found."}), 404 @@ -98,6 +108,8 @@ def view_code(project_id): @main.route("/project//download") def download_code(project_id): """Serve the starter code file as a downloadable attachment.""" + if 'email' not in session: + return redirect('/login') project = find_project_by_id(project_id) if not project: abort(404) diff --git a/static/script.js b/static/script.js index 9d96ab9..260b8c7 100644 --- a/static/script.js +++ b/static/script.js @@ -543,7 +543,16 @@ if (clearFiltersBtn) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) //convert object to json string }) +auth-feature + //means user not logged in/session expired + .then(function (res) { if(res.status===401){ + window.location.href="/login"; + return; + } + return res.json(); }) + .then(function (res) { return res.json(); }) //parse the response as JSON + main .then(function (data) { setLoadingState(false); @@ -751,7 +760,11 @@ if (isDetailPage) { if (codeContentEl) codeContentEl.textContent = "Loading starter code..."; fetch("/project/" + PROJECT_ID + "/code") - .then(function (res) { return res.json(); }) + //means user not logged in/session expired + .then(function (res) {if(res.status===401){ + window.location.href="/login"; + return; + } return res.json(); }) .then(function (data) { if (data.error) { if (codeContentEl) codeContentEl.textContent = "Error: " + data.error; @@ -955,3 +968,14 @@ if (scrollTopBtn) { window.addEventListener('scroll', handleScroll); scrollTopBtn.addEventListener('click', scrollToTop); } +/*Flash Messages Auto Handle to remove after a certain time*/ +setTimeout(function () { + document.querySelectorAll(".flash-message").forEach(function (msg) { + msg.style.opacity = "0"; + msg.style.transform = "translateY(-10px)"; + + setTimeout(function () { + msg.remove(); + }, 300); + }); +}, 3000); diff --git a/static/style.css b/static/style.css index 13cd1c9..38f8d0a 100644 --- a/static/style.css +++ b/static/style.css @@ -345,6 +345,24 @@ ol { transition: color var(--t); } +.nav-link-login { + border: 1px solid rgba(255,255,255,0.15); + padding: 8px 16px; + border-radius: 10px; +} + +.nav-link-signup { + background: linear-gradient(135deg, #7c3aed, #2563eb); + color: white !important; + padding: 8px 16px; + border-radius: 10px; + font-weight: 600; +} + +.nav-link-signup:hover { + transform: translateY(-1px); +} + /* Animated underline for mobile links */ .nav-mobile-link::after { content: ''; @@ -2816,6 +2834,100 @@ select:focus { } #scroll-top-btn.visible { +auth-feature + display: flex; +} +/*Styling for input fields in login and signup pages*/ +input { + width: 100%; + padding: 12px 14px; + border: 1.5px solid var(--border); + border-radius: var(--r-md); + font-size: 0.95rem; + font-family: inherit; + outline: none; + transition: 0.2s ease; + background: var(--white); +} + +input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +/*Google Login styling*/ +.google-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + + width: 100%; + padding: 12px; + + margin-top: 10px; + + border: 1px solid #ddd; + border-radius: 10px; + + text-decoration: none; + font-weight: 600; + color: #333; + background: #fff; + + transition: 0.2s ease; +} + +.google-btn:hover { + background: #f5f5f5; + transform: translateY(-1px); +} + +/*Flash Messages Styling*/ +.flash-container{ + width: min(900px, 92%); + margin: 100px auto 20px auto; + position: relative; + z-index: 999; +} + +.flash-message{ + padding: 16px 20px; + border-radius: 14px; + margin-bottom: 14px; + font-weight: 600; + font-size: 15px; + line-height: 1.5; + animation: fadeSlide 0.3s ease; + transition: all 0.3s ease; + + display: flex; + align-items: center; + gap: 10px; +} + +.flash-success{ + background: rgba(34,197,94,0.15); + color: #22c55e; + border: 1px solid rgba(34,197,94,0.35); +} + +.flash-error{ + background: rgba(239,68,68,0.15); + color: #ef4444; + border: 1px solid rgba(239,68,68,0.35); +} + +@keyframes fadeSlide{ + from{ + opacity: 0; + transform: translateY(-10px); + } + to{ + opacity: 1; + transform: translateY(0); + } +======= display: flex; } @@ -2845,4 +2957,5 @@ select:focus { flex: 1; white-space: pre; color: #e6edf3; +main } \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..dc0291a --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,136 @@ + + + + + + Dashboard — DevPath + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} +{% endwith %} +
+
+
+ Your Dashboard +
+

+ Welcome, {{ user.name }} +

+

+ Manage your DevPath account and skills. +

+
+
+

+ Profile Information +

+

+ Name: + {{ user.name }} +

+

+ Email: + {{ user.email }} +

+
+
+

+ Account Status +

+

+ Logged in successfully. +

+

+ Your session will expire after 7 days of inactivity. +

+
+
+

+ Start Exploring +

+

+ Find coding projects tailored to your skills and interests. +

+ + Find Projects + +
+
+
+
+ + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 18537b9..d524f33 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,6 +28,14 @@ Features Find Project GitHub + + {% if session.get('email') %} + Dashboard + + {% else %} + Login + SignUp + {% endif %} + +
+ OR +
+ + + Google + Continue with Google + +

+ Don't have an account? + Create one +

+ + + + + + \ No newline at end of file diff --git a/templates/project.html b/templates/project.html index 85fa923..f546e27 100644 --- a/templates/project.html +++ b/templates/project.html @@ -33,6 +33,13 @@ diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000..8bd72eb --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,92 @@ + + + + + + Signup — DevPath + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} +
+
+
+ Join DevPath +
+

+ Create Your Account +

+

+ Save your skills, track projects, and personalize recommendations. +

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ {% if error %} +

+ {{ error }} +

+ {% endif %} + +
+

+ Already have an account? + Login +

+
+
+
+
+ + \ No newline at end of file