diff --git a/dist/index.html b/dist/index.html index e864e00a..f4a2a8b4 100644 --- a/dist/index.html +++ b/dist/index.html @@ -4,6 +4,7 @@ + Capstone 1 diff --git a/package-lock.json b/package-lock.json index dc268f09..db8ff7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@auth0/auth0-react": "^2.3.0", + "@auth0/auth0-react": "^2.4.0", "@babel/core": "^7.27.4", "@babel/preset-react": "^7.27.1", "axios": "^1.10.0", @@ -45,12 +45,12 @@ } }, "node_modules/@auth0/auth0-react": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.3.0.tgz", - "integrity": "sha512-YYTc/DWWigKC9fURufR/79h3+3DAnIzbfEzJLZ8Z4Q0BXE0azru3pKUbU+vYzS4lMAJkclwLuAbUnLjK81vCpA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.4.0.tgz", + "integrity": "sha512-5bt3sO9FVupNM15IpqyYu/2OPHpLI5El7RgWLQXZOPbnCBbtl+VgdHR+H2NfhNQ4SqQtC/5uKbHWafcVcsxkiw==", "license": "MIT", "dependencies": { - "@auth0/auth0-spa-js": "^2.1.3" + "@auth0/auth0-spa-js": "^2.2.0" }, "peerDependencies": { "react": "^16.11.0 || ^17 || ^18 || ^19", diff --git a/package.json b/package.json index da1486c6..83ce0264 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "license": "ISC", "description": "", "dependencies": { - "@auth0/auth0-react": "^2.3.0", + "@auth0/auth0-react": "^2.4.0", "@babel/core": "^7.27.4", "@babel/preset-react": "^7.27.1", "axios": "^1.10.0", diff --git a/src/App.jsx b/src/App.jsx index d793b9af..86f593ed 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,24 +2,40 @@ import React, { useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; import axios from "axios"; import "./AppStyles.css"; +import { BrowserRouter as Router, Routes, Route, useNavigate } from "react-router-dom"; +import { API_URL } from "./shared"; +import { Auth0Provider, useAuth0 } from "@auth0/auth0-react"; +import { auth0Config } from "./auth0-config"; + import NavBar from "./components/NavBar"; -import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import Login from "./components/Login"; import Signup from "./components/Signup"; import Home from "./components/Home"; import NotFound from "./components/NotFound"; -import { API_URL } from "./shared"; +import SpotifyConnect from "./components/SpotifyConnect"; +import SpotifyCallback from "./components/SpotifyCallback"; const App = () => { const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + const { + isAuthenticated, + isLoading: auth0Loading, + user: auth0User, + logout: auth0Logout, + } = useAuth0(); const checkAuth = async () => { try { const response = await axios.get(`${API_URL}/auth/me`, { withCredentials: true, }); - setUser(response.data.user); - } catch { + if (response.data.user) { + setUser(response.data.user); + } + } catch (error) { console.log("Not authenticated"); setUser(null); } @@ -27,25 +43,74 @@ const App = () => { // Check authentication status on app load useEffect(() => { - checkAuth(); - }, []); + const initAuth = async () => { + if (!auth0Loading) { + if (isAuthenticated && auth0User) { + await handleAuth0Login(); + } else { + await checkAuth(); + } + setLoading(false); + } + }; + initAuth(); + }, [isAuthenticated, auth0User, auth0Loading]); - const handleLogout = async () => { + const handleAuth0Login = async () => { try { - // Logout from our backend - await axios.post( - `${API_URL}/auth/logout`, - {}, + setLoading(true); + const response = await axios.post( + `${API_URL}/auth/auth0`, + { + auth0Id: auth0User.sub, + email: auth0User.email, + username: auth0User.nickname || auth0User.email?.split("@")[0], + }, { withCredentials: true, } ); - setUser(null); + + if (response.data.token) { + localStorage.setItem('token', response.data.token); + } + + setUser(response.data.user); + navigate("/"); + } catch (error) { + console.error("Auth0 login error:", error); + } finally { + setLoading(false); + } + }; + + const handleLogout = async () => { + try { + await axios.post(`${API_URL}/auth/logout`, {}, { + withCredentials: true, + }); } catch (error) { console.error("Logout error:", error); + } finally { + localStorage.removeItem('token'); + setUser(null); + + if (isAuthenticated) { + auth0Logout({ + logoutParams: { + returnTo: window.location.origin, + }, + }); + } else { + navigate("/"); + } } }; + if (loading || auth0Loading) { + return
Loading...
; + } + return (
@@ -53,7 +118,9 @@ const App = () => { } /> } /> - } /> + } /> + } /> + } /> } />
@@ -63,11 +130,13 @@ const App = () => { const Root = () => { return ( - - - + + + + + ); }; const root = createRoot(document.getElementById("root")); -root.render(); +root.render(); \ No newline at end of file diff --git a/src/auth0-config.js b/src/auth0-config.js new file mode 100644 index 00000000..8fb1e6a2 --- /dev/null +++ b/src/auth0-config.js @@ -0,0 +1,9 @@ +export const auth0Config = { + domain: process.env.REACT_APP_AUTH0_DOMAIN || "franccescopetta.us.auth0.com", + clientId: process.env.REACT_APP_AUTH0_CLIENT_ID || "h1SYjGM6qWwIZMRTOSI7yjdjEzp3iAkS", + authorizationParams: { + redirect_uri: `${window.location.origin}/login`, + audience: process.env.REACT_APP_AUTH0_AUDIENCE, + scope: "openid profile email", + }, +}; \ No newline at end of file diff --git a/src/components/CSS/AuthStyles.css b/src/components/CSS/AuthStyles.css new file mode 100644 index 00000000..bd96886f --- /dev/null +++ b/src/components/CSS/AuthStyles.css @@ -0,0 +1,146 @@ +.auth-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; + padding: 20px; +} + +.auth-form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; +} + +.auth-form h2 { + text-align: center; + margin-bottom: 1.5rem; + color: #333; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.form-group input.error { + border-color: #dc3545; +} + +.error-text { + color: #dc3545; + font-size: 0.875rem; + margin-top: 0.25rem; + display: block; +} + +.error-message { + background-color: #f8d7da; + color: #721c24; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + border: 1px solid #f5c6cb; +} + +.auth-form button { + width: 100%; + padding: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.auth-form button:hover:not(:disabled) { + background-color: #0056b3; +} + +.auth-form button:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +.auth-divider { + text-align: center; + margin: 1.5rem 0; + position: relative; +} + +.auth-divider::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background-color: #ddd; +} + +.auth-divider span { + background-color: white; + padding: 0 1rem; + color: #666; + font-size: 0.875rem; +} + +.auth0-login-btn { + width: 100%; + padding: 0.75rem; + background-color: #eb5424; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + margin-bottom: 1rem; +} + +.auth0-login-btn:hover { + background-color: #d4451d; +} + +.auth-link { + text-align: center; + margin-top: 1rem; + color: #666; +} + +.auth-link a { + color: #007bff; + text-decoration: none; +} + +.auth-link a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/src/components/Login.jsx b/src/components/Login.jsx index 849e495a..4d853396 100644 --- a/src/components/Login.jsx +++ b/src/components/Login.jsx @@ -1,8 +1,10 @@ -import React, { useState } from "react"; -import { useNavigate, Link } from "react-router-dom"; +import React, { useState, useEffect } from "react"; +import { useNavigate, Link, useLocation } from "react-router-dom"; +import { useAuth0 } from "@auth0/auth0-react"; import axios from "axios"; import { API_URL } from "../shared"; -import "./AuthStyles.css"; +import "./CSS/AuthStyles.css"; +import { auth0Config } from "../auth0-config"; const Login = ({ setUser }) => { const [formData, setFormData] = useState({ @@ -11,7 +13,23 @@ const Login = ({ setUser }) => { }); const [errors, setErrors] = useState({}); const [isLoading, setIsLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); const navigate = useNavigate(); + const location = useLocation(); + const { loginWithRedirect, isAuthenticated, user: auth0User, isLoading: auth0Loading } = useAuth0(); + + useEffect(() =>{ + if(!auth0Loading && isAuthenticated && auth0User){ + navigate("/"); + } + }, [isAuthenticated, auth0User, auth0Loading, navigate]); + + useEffect(() => { + if (location.state?.message) { + setSuccessMessage(location.state.message); + window.history.replaceState({}, document.title); + } + }, [location]); const validateForm = () => { const newErrors = {}; @@ -40,14 +58,22 @@ const Login = ({ setUser }) => { } setIsLoading(true); + setSuccessMessage(""); + try { const response = await axios.post(`${API_URL}/auth/login`, formData, { withCredentials: true, }); + if (response.data.token) { + localStorage.setItem('token', response.data.token); + } + setUser(response.data.user); + navigate("/"); } catch (error) { + console.error("Login error:", error); if (error.response?.data?.error) { setErrors({ general: error.response.data.error }); } else { @@ -65,7 +91,6 @@ const Login = ({ setUser }) => { [name]: value, })); - // Clear error when user starts typing if (errors[name]) { setErrors((prev) => ({ ...prev, @@ -74,11 +99,28 @@ const Login = ({ setUser }) => { } }; + const handleAuth0Login = () => { + loginWithRedirect(); + }; + + if (auth0Loading) { + return ( +
+
Loading...
+
+ ); + } + + return (

Login

+ {successMessage && ( +
{successMessage}
+ )} + {errors.general && (
{errors.general}
)} @@ -93,6 +135,7 @@ const Login = ({ setUser }) => { value={formData.username} onChange={handleChange} className={errors.username ? "error" : ""} + placeholder="Enter your username" /> {errors.username && ( {errors.username} @@ -108,17 +151,31 @@ const Login = ({ setUser }) => { value={formData.password} onChange={handleChange} className={errors.password ? "error" : ""} + placeholder="Enter your password" /> {errors.password && ( {errors.password} )}
- +
+ or +
+ + +

Don't have an account? Sign up

@@ -127,4 +184,4 @@ const Login = ({ setUser }) => { ); }; -export default Login; +export default Login; \ No newline at end of file diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx index 648f3808..d2f7da29 100644 --- a/src/components/NavBar.jsx +++ b/src/components/NavBar.jsx @@ -12,6 +12,9 @@ const NavBar = ({ user, onLogout }) => {
{user ? (
+ + Spotify + Welcome, {user.username}!
- +
+ or +
+ + +

- Already have an account? Login + Already have an account? Log in

); }; -export default Signup; +export default Signup; \ No newline at end of file diff --git a/src/components/SpotifyCallback.jsx b/src/components/SpotifyCallback.jsx new file mode 100644 index 00000000..532855c5 --- /dev/null +++ b/src/components/SpotifyCallback.jsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import axios from "axios"; +import { API_URL } from "../shared"; + +const SpotifyCallback = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState("processing"); + + useEffect(() => { + const handleCallback = async () => { + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + if (error) { + setStatus("error"); + setTimeout(() => navigate("/"), 3000); + return; + } + + if (!code) { + setStatus("error"); + setTimeout(() => navigate("/"), 3000); + return; + } + + try { + await axios.post( + `${API_URL}/auth/spotify/callback`, + { code, state }, + { withCredentials: true } + ); + setStatus("success"); + setTimeout(() => navigate("/"), 2000); + } catch (error) { + console.error("Spotify callback error:", error); + setStatus("error"); + setTimeout(() => navigate("/"), 3000); + } + }; + + handleCallback(); + }, [searchParams, navigate]); + + return ( +
+ {status === "processing" &&
Connecting to Spotify...
} + {status === "success" &&
✅ Spotify connected successfully! Redirecting...
} + {status === "error" &&
❌ Failed to connect Spotify. Redirecting...
} +
+ ); +}; + +export default SpotifyCallback; \ No newline at end of file diff --git a/src/components/SpotifyConnect.jsx b/src/components/SpotifyConnect.jsx new file mode 100644 index 00000000..af5e5ccc --- /dev/null +++ b/src/components/SpotifyConnect.jsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { API_URL } from "../shared"; +import { useNavigate } from "react-router-dom"; + +const SpotifyConnect = () => { + const [spotifyData, setSpotifyData] = useState(null); + const [loading, setLoading] = useState(true); + const [topTracks, setTopTracks] = useState([]); + const [user, setUser] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + checkUserAuth(); + }, []); + + useEffect(() => { + checkSpotifyConnection(); + }, []); + + const checkUserAuth = async () => { + try { + const authResponse = await axios.get(`${API_URL}/auth/me`, { + withCredentials: true, + }); + + if (!authResponse.data.user) { + navigate("/login"); + return; + } + + setUser(authResponse.data.user); + await checkSpotifyConnection(); + } catch (error) { + console.error("Auth check failed:", error); + navigate("/login"); + } + }; + + const checkSpotifyConnection = async () => { + try { + const response = await axios.get(`${API_URL}/auth/spotify/profile`, { + withCredentials: true, + }); + setSpotifyData(response.data); + } catch (error) { + console.error("Error checking Spotify connection:", error); + setSpotifyData({ connected: false }); + } finally { + setLoading(false); + } + }; + + const connectSpotify = async () => { + try { + const response = await axios.get(`${API_URL}/auth/spotify/auth-url`, { + withCredentials: true, + }); + window.location.href = response.data.authUrl; + } catch (error) { + console.error("Error getting Spotify auth URL:", error); + } + }; + + const getTopTracks = async () => { + try { + const response = await axios.get(`${API_URL}/auth/spotify/top-tracks`, { + withCredentials: true, + }); + setTopTracks(response.data.items); + } catch (error) { + console.error("Error getting top tracks:", error); + } + }; + + const disconnectSpotify = async () => { + try { + await axios.delete(`${API_URL}/auth/spotify/disconnect`, { + withCredentials: true, + }); + setSpotifyData({ connected: false }); + setTopTracks([]); + } catch (error) { + console.error("Error disconnecting Spotify:", error); + } + }; + + if (loading) return
Loading...
; + + return ( +
+

Spotify Integration

+ + {!spotifyData?.connected ? ( +
+

Connect your Spotify account to see your music data!

+ +
+ ) : ( +
+
+

Connected as: {spotifyData.profile?.display_name}

+ {spotifyData.profile?.images?.[0] && ( + Profile + )} +
+ +
+ + +
+ + {topTracks.length > 0 && ( +
+

Your Top Tracks

+ {topTracks.map((track, index) => ( +
+ {index + 1}. + {track.name} by{" "} + {track.artists.map((a) => a.name).join(", ")} +
+ ))} +
+ )} +
+ )} +
+ ); +}; + +export default SpotifyConnect; diff --git a/src/shared.js b/src/shared.js index 818db4f2..2d0f6045 100644 --- a/src/shared.js +++ b/src/shared.js @@ -1 +1 @@ -export const API_URL = process.env.API_URL || "http://localhost:8080"; +export const API_URL = process.env.API_URL || "https://capstone-2-backend-three.vercel.app"; diff --git a/webpack.config.js b/webpack.config.js index bfa8e19e..6130df12 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,10 +13,15 @@ module.exports = { devtool: "source-map", plugins: [ new webpack.EnvironmentPlugin({ - API_URL: "http://localhost:8080", - REACT_APP_AUTH0_DOMAIN: "", - REACT_APP_AUTH0_CLIENT_ID: "", - REACT_APP_AUTH0_AUDIENCE: "", + API_URL: + process.env.API_URL || "https://capstone-2-backend-three.vercel.app", + REACT_APP_AUTH0_DOMAIN: + process.env.REACT_APP_AUTH0_DOMAIN || "franccescopetta.us.auth0.com", + REACT_APP_AUTH0_CLIENT_ID: + process.env.REACT_APP_AUTH0_CLIENT_ID || "h1SYjGM6qWwIZMRTOSI7yjdjEzp3iAkS", + REACT_APP_AUTH0_AUDIENCE: + process.env.REACT_APP_AUTH0_AUDIENCE || + "https://franccescopetta.us.auth0.com/api/v2/", }), ], module: {