diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index 5fc95b7d..386148fe 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -319,6 +319,49 @@ export class AuthController { ); } + /** + * GET /api/auth/session + * Returns the authenticated user's display identity and organization. + */ + static async session(req: express.Request, res: express.Response) { + try { + const result = await query( + `SELECT + u.id, + u.email, + u.name, + u.wallet_address, + u.organization_id, + u.role, + o.name AS organization_name + FROM users u + LEFT JOIN organizations o ON o.id = u.organization_id + WHERE u.id = $1`, + [req.user!.id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = result.rows[0]; + return res.json({ + user: { + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + walletAddress: user.wallet_address ?? null, + organizationId: user.organization_id ?? null, + organizationName: user.organization_name ?? null, + role: user.role, + }, + }); + } catch (error) { + console.error('Session lookup failed:', error); + return res.status(500).json({ error: 'Internal server error' }); + } + } + /** * POST /api/auth/refresh * Refreshes access token using a valid refresh token. diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 16f75526..1183d365 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -10,6 +10,7 @@ const router = Router(); router.post('/login', AuthController.login); router.post('/register', AuthController.register); router.post('/refresh', AuthController.refresh); +router.get('/session', authenticateJWT, AuthController.session); router.post( '/invitations', diff --git a/frontend/src/components/AppNav.tsx b/frontend/src/components/AppNav.tsx index ced8ef3b..b03e7017 100644 --- a/frontend/src/components/AppNav.tsx +++ b/frontend/src/components/AppNav.tsx @@ -13,18 +13,15 @@ import { X, BarChart2, TrendingUp, + LogOut, } from 'lucide-react'; import { Avatar } from './Avatar'; +import { useAuth } from '../providers/AuthProvider'; const AppNav: React.FC = () => { const [mobileOpen, setMobileOpen] = useState(false); - // Mock user data - replace with actual user context - const currentUser = { - email: 'user@example.com', - name: 'John Doe', - imageUrl: undefined, - }; + const { user, loading, logout } = useAuth(); const navLinks = ( <> @@ -202,6 +199,49 @@ const AppNav: React.FC = () => { ); + const displayName = user?.name || user?.email || user?.walletAddress || ''; + const displayDetail = user?.email || user?.walletAddress || ''; + + const renderUserProfile = (showDetails: boolean) => + loading ? ( +
+ ) : user ? ( +
+
+ +
+

{displayName}

+

{displayDetail}

+
+
+ +
+ ) : ( + + Sign in + + ); + return (
{/* Mobile menu footer with user info */} -
-
- -
-

- {currentUser.name} -

-

{currentUser.email}

-
-
-
+
{renderUserProfile(true)}
diff --git a/frontend/src/components/DashboardTopBar.tsx b/frontend/src/components/DashboardTopBar.tsx index 617350d5..63e4dcb3 100644 --- a/frontend/src/components/DashboardTopBar.tsx +++ b/frontend/src/components/DashboardTopBar.tsx @@ -2,10 +2,10 @@ import React from 'react'; import ThemeToggle from './ThemeToggle'; import ConnectAccount from './ConnectAccount'; import { Text } from '@stellar/design-system'; +import { useAuth } from '../providers/AuthProvider'; export const DashboardTopBar: React.FC = () => { - // Mock org data - const orgName = 'FutureLabs Inc.'; + const { user, loading } = useAuth(); const balance = '1,250.45 USDC'; return ( @@ -20,9 +20,16 @@ export const DashboardTopBar: React.FC = () => { > Organization - - {orgName} - + {loading ? ( + + ) : ( + + {user?.organizationName ?? 'No organization'} + + )}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f84f1879..0a14c323 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { WalletProvider } from './providers/WalletProvider.tsx'; import { NotificationProvider } from './providers/NotificationProvider.tsx'; import { SocketProvider } from './providers/SocketProvider.tsx'; import { ThemeProvider } from './providers/ThemeProvider.tsx'; +import { AuthProvider } from './providers/AuthProvider.tsx'; import * as Sentry from '@sentry/react'; import ErrorBoundary from './components/ErrorBoundary'; import ErrorFallback from './components/ErrorFallback'; @@ -35,9 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - {}} />}> - - + + {}} />}> + + + diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx new file mode 100644 index 00000000..3342a8df --- /dev/null +++ b/frontend/src/providers/AuthProvider.tsx @@ -0,0 +1,108 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import axios from 'axios'; +import { useLocation } from 'react-router-dom'; +import api from '../utils/api'; + +export interface AuthUser { + id: number; + email: string | null; + name: string | null; + walletAddress: string | null; + organizationId: number | null; + organizationName: string | null; + role: string; +} + +interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +function storedToken() { + return localStorage.getItem('payd_auth_token'); +} + +export const AuthProvider: React.FC = ({ children }) => { + const location = useLocation(); + const [token, setToken] = useState(() => storedToken()); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(() => Boolean(storedToken())); + + const loadSession = useCallback(async (currentToken: string | null) => { + if (!currentToken) { + setUser(null); + setLoading(false); + return; + } + + setLoading(true); + try { + const response = await api.get<{ user: AuthUser }>('/auth/session'); + setUser(response.data.user); + } catch (error) { + setUser(null); + + if ( + axios.isAxiosError(error) && + (error.response?.status === 401 || + error.response?.status === 403 || + error.response?.status === 404) + ) { + localStorage.removeItem('payd_auth_token'); + localStorage.removeItem('accessToken'); + setToken(null); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const currentToken = storedToken(); + if (currentToken !== token) { + setToken(currentToken); + } + }, [location.pathname, token]); + + useEffect(() => { + void loadSession(token); + }, [loadSession, token]); + + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key === 'payd_auth_token') { + setToken(event.newValue); + } + }; + + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem('payd_auth_token'); + localStorage.removeItem('accessToken'); + setToken(null); + setUser(null); + window.location.assign('/login'); + }, []); + + return {children}; +}; + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}