diff --git a/frontend/public/apple.svg b/frontend/public/apple.svg new file mode 100644 index 0000000..00d09ec --- /dev/null +++ b/frontend/public/apple.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/facebook.svg b/frontend/public/facebook.svg new file mode 100644 index 0000000..4296d1a --- /dev/null +++ b/frontend/public/facebook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/google.svg b/frontend/public/google.svg new file mode 100644 index 0000000..a0d96bc --- /dev/null +++ b/frontend/public/google.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/microsoft.svg b/frontend/public/microsoft.svg new file mode 100644 index 0000000..603cbf9 --- /dev/null +++ b/frontend/public/microsoft.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/twitter.svg b/frontend/public/twitter.svg new file mode 100644 index 0000000..d29bcfe --- /dev/null +++ b/frontend/public/twitter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3ba7157..110b33b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import Layout from './components/Layout'; import ProtectedRoute from './components/ProtectedRoute'; import SetupProtectedRoute from './components/SetupProtectedRoute'; import RecurringTransactions from './pages/RecurringTransactions'; +import AuthCallbackPage from './pages/AuthCallbackPage'; function App() { return ( @@ -25,6 +26,7 @@ function App() { } /> } /> } /> + } /> {/* Protected Routes */} { - const token = localStorage.getItem('token'); - if (token) { - config.headers['Authorization'] = `Bearer ${token}`; + // If header already set via setAuthToken above, keep it. + if (!config.headers?.Authorization) { + const token = localStorage.getItem('token'); + if (token) { + config.headers = config.headers || {}; + config.headers['Authorization'] = `Bearer ${token}`; + } } return config; }, + (error) => Promise.reject(error) +); + +/** + * Response interceptor: + * - If 401 returned, optionally trigger logout handler (to be implemented in app) + * - Normalize error to include response data for easier handling in components + */ +instance.interceptors.response.use( + (res) => res, (error) => { - return Promise.reject(error); + const status = error?.response?.status; + if (status === 401) { + // optional: try refresh token flow here if you implement it. + // Fallback: clear token and call a logout handler if provided. + try { + // Importing directly would create a circular dependency with AuthContext. + // So we call a small external helper. You can implement it to dispatch logout. + if (typeof defaultLogout === 'function') { + defaultLogout(); + } else { + clearAuthToken(); + } + } catch (e) { + clearAuthToken(); + } + } + + // Normalize the error for consumers + const normalized = { + message: error.message, + status: error?.response?.status, + data: error?.response?.data, + }; + return Promise.reject(normalized); } ); diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index a82e76b..2a7ef9e 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,7 +1,7 @@ import React, { createContext, useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import { useNavigate } from 'react-router-dom'; -import api from '../api/axios'; +import api, { setAuthToken, clearAuthToken } from '../api/axios'; const AuthContext = createContext(); @@ -85,6 +85,29 @@ export const AuthProvider = ({ children }) => { } }; + const loginWithOAuth = async (jwtToken) => { + try { + // Save the token and set authorization header + localStorage.setItem('token', jwtToken); + setToken(jwtToken); + setAuthToken(jwtToken); + + // Fetch user profile from backend + const response = await api.get('/auth/me'); + setUser(response.data); + + setPendingToast({ type: 'success', message: 'Login successful!' }); + navigate('/dashboard'); + } catch (error) { + console.error('OAuth login failed', error); + localStorage.removeItem('token'); + setToken(null); + setUser(null); + setPendingToast({ type: 'error', message: 'OAuth login failed. Please try again.' }); + navigate('/login'); + } + }; + const logout = () => { setPendingToast({ type: 'info', message: 'Logged out successfully.' }); setUser(null); @@ -108,7 +131,7 @@ export const AuthProvider = ({ children }) => { }; return ( - + {children} ); diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js index 7cf27f3..0363728 100644 --- a/frontend/src/hooks/useAuth.js +++ b/frontend/src/hooks/useAuth.js @@ -1,8 +1,40 @@ import { useContext } from 'react'; import AuthContext from '../contexts/AuthContext'; +/** + * useAuth + * returns the AuthContext plus small OAuth helpers: + * - startOAuth(provider) -> navigates to backend auth URL + * - handleOAuthCallback(token) -> delegates to loginWithOAuth from context + */ const useAuth = () => { - return useContext(AuthContext); + const ctx = useContext(AuthContext); + + // Start OAuth flow by redirecting to backend auth endpoint + const startOAuth = (provider) => { + // Ensure VITE_API_URL is set in your frontend env (e.g. http://localhost:5000) + const base = import.meta.env.VITE_API_URL || ''; + // providers: 'google' | 'github' + const url = `${base.replace(/\/$/, '')}/api/auth/${provider}`; + // navigation via location so it works for popups/redirects + window.location.href = url; + }; + + // Handle callback by calling the context's loginWithOAuth (if available) + const handleOAuthCallback = async (token) => { + if (!token) return null; + if (typeof ctx.loginWithOAuth === 'function') { + return ctx.loginWithOAuth(token); + } + console.warn('loginWithOAuth not available on AuthContext'); + return null; + }; + + return { + ...ctx, + startOAuth, + handleOAuthCallback, + }; }; export default useAuth; \ No newline at end of file diff --git a/frontend/src/pages/AuthCallbackPage.jsx b/frontend/src/pages/AuthCallbackPage.jsx new file mode 100644 index 0000000..919a3c7 --- /dev/null +++ b/frontend/src/pages/AuthCallbackPage.jsx @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import useAuth from "../hooks/useAuth"; + +export default function AuthCallbackPage() { + const navigate = useNavigate(); + const { handleOAuthCallback } = useAuth(); // unified from your useAuth.js + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + + async function processOAuth() { + if (token) { + try { + await handleOAuthCallback(token); + // if successful, user will be redirected by AuthContext/loginWithOAuth + } catch (err) { + console.error("OAuth callback failed:", err); + navigate("/login"); + } + } else { + navigate("/login"); + } + } + + processOAuth(); + }, [handleOAuthCallback, navigate]); + + return

Signing you in...

; +} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index fec46f5..8857c02 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import useAuth from '../hooks/useAuth'; import PasswordInput from '../components/PasswordInput'; +import startOAuth from '../hooks/useAuth'; export default function RegisterPage() { const [email, setEmail] = useState(''); @@ -57,7 +58,7 @@ export default function RegisterPage() { return (
- + Paisable
@@ -92,6 +93,28 @@ export default function RegisterPage() { Create Account
+
+
+ or sign in using +
+
+
+ + + + + +
Already have an account? diff --git a/frontend/src/utils/logoutHelper.js b/frontend/src/utils/logoutHelper.js new file mode 100644 index 0000000..07b9dc3 --- /dev/null +++ b/frontend/src/utils/logoutHelper.js @@ -0,0 +1,16 @@ +import clearAuthToken from '../api/axios'; +// frontend/src/utils/logoutHelper.js +// simple helper to call window location or dispatch a custom event the AuthContext listens to + +export function logout() { + // simplest: reload to /login and clear token + try { + localStorage.removeItem('token'); + clearAuthToken(); + // Optionally broadcast an event for AuthContext to pick up + window.dispatchEvent(new Event('app:logout')); + } finally { + // navigate to login page explicitly + window.location.href = '/login'; + } +}