Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions backend/src/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions backend/src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
84 changes: 48 additions & 36 deletions frontend/src/components/AppNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<>
Expand Down Expand Up @@ -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 ? (
<div
aria-label="Loading account"
className="h-9 w-40 rounded-lg bg-(--surface-hi) animate-pulse"
/>
) : user ? (
<div className="flex items-center gap-2 min-w-0">
<div className="p-1 bg-(--surface-hi) rounded-lg flex items-center gap-2 min-w-0">
<Avatar
email={user.email || user.walletAddress || String(user.id)}
name={displayName}
size="sm"
/>
<div
className={`${showDetails ? 'block' : 'hidden md:block'} flex-1 min-w-0`}
>
<p className="text-[10px] font-semibold text-(--text) truncate">{displayName}</p>
<p className="text-[10px] text-(--muted) truncate">{displayDetail}</p>
</div>
</div>
<button
type="button"
onClick={logout}
aria-label="Sign out"
title="Sign out"
className="p-2 rounded-lg text-(--muted) hover:text-(--text) hover:bg-(--surface-hi) transition"
>
<LogOut className="w-4 h-4" />
</button>
</div>
) : (
<Link
to="/login"
className="px-3 py-1.5 rounded-lg text-[13px] font-semibold text-(--link) hover:text-(--accent) transition"
>
Sign in
</Link>
);

return (
<nav className="relative w-full">
<div className="flex items-center justify-between gap-4 px-3 py-2">
Expand All @@ -220,20 +260,7 @@ const AppNav: React.FC = () => {
</button>

{/* User profile */}
<div className="ml-auto flex items-center gap-2">
<div className="p-1 bg-(--surface-hi) rounded-lg flex items-center gap-2">
<Avatar
email={currentUser.email}
name={currentUser.name}
imageUrl={currentUser.imageUrl}
size="sm"
/>
<div className="hidden md:block flex-1 min-w-0">
<p className="text-[10px] font-semibold text-(--text) truncate">{currentUser.name}</p>
<p className="text-[10px] text-(--muted) truncate">{currentUser.email}</p>
</div>
</div>
</div>
<div className="ml-auto flex items-center gap-2">{renderUserProfile(false)}</div>
</div>

{/* Mobile dropdown menu */}
Expand Down Expand Up @@ -274,22 +301,7 @@ const AppNav: React.FC = () => {
</div>

{/* Mobile menu footer with user info */}
<div className="p-4 border-t border-(--border)">
<div className="flex items-center gap-3 p-3 bg-(--surface-hi) rounded-lg">
<Avatar
email={currentUser.email}
name={currentUser.name}
imageUrl={currentUser.imageUrl}
size="sm"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-(--text) truncate">
{currentUser.name}
</p>
<p className="text-xs text-(--muted) truncate">{currentUser.email}</p>
</div>
</div>
</div>
<div className="p-4 border-t border-(--border)">{renderUserProfile(true)}</div>
</div>
</div>
</>
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/components/DashboardTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,9 +20,16 @@ export const DashboardTopBar: React.FC = () => {
>
Organization
</Text>
<Text as="span" size="sm" weight="bold" addlClassName="text-(--text)">
{orgName}
</Text>
{loading ? (
<span
aria-label="Loading organization"
className="mt-1 h-4 w-28 rounded bg-(--surface-hi) animate-pulse"
/>
) : (
<Text as="span" size="sm" weight="bold" addlClassName="text-(--text)">
{user?.organizationName ?? 'No organization'}
</Text>
)}
</div>

<div className="w-px h-6 bg-(--border) hidden lg:block" />
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,9 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<SocketProvider>
<WalletProvider>
<BrowserRouter>
<ErrorBoundary fallback={<ErrorFallback onReset={() => {}} />}>
<App />
</ErrorBoundary>
<AuthProvider>
<ErrorBoundary fallback={<ErrorFallback onReset={() => {}} />}>
<App />
</ErrorBoundary>
</AuthProvider>
</BrowserRouter>
</WalletProvider>
</SocketProvider>
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/providers/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthContextValue | undefined>(undefined);

function storedToken() {
return localStorage.getItem('payd_auth_token');
}

export const AuthProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const location = useLocation();
const [token, setToken] = useState<string | null>(() => storedToken());
const [user, setUser] = useState<AuthUser | null>(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 <AuthContext.Provider value={{ user, loading, logout }}>{children}</AuthContext.Provider>;
};

export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}