Skip to content

Commit 73232f7

Browse files
committed
ranking: a bunhc of frontend improvements. added a me endpoint for fetchikng info about the logged in user. added protectedroutes that allow us to block certain types of viewers from viewing certain pages so particiapnts can't access the volunteer intake form and vice versa. added a basic unauthorized page to go with this (we can improve this styling/look later) as well as a basic loading skeleton (which can also potentially be improved visually later). also updated teh intake form thank you screen to match the figma text. only thing left for forms i think is the logic for showing the user certain pages/forms based on what they've already copmleted and what step the admin has put them in. this i think could go in a separate pr
1 parent c5b30af commit 73232f7

File tree

13 files changed

+544
-208
lines changed

13 files changed

+544
-208
lines changed

backend/app/routes/auth.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from fastapi import APIRouter, Depends, HTTPException, Request, Response
22
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3+
from sqlalchemy.orm import Session
34

5+
from ..models.User import User
46
from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token
57
from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole
68
from ..services.implementations.auth_service import AuthService
79
from ..services.implementations.user_service import UserService
10+
from ..utilities.db_utils import get_db
811
from ..utilities.service_utils import get_auth_service, get_user_service
912

1013
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -96,3 +99,37 @@ async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_
9699
# Log unexpected errors
97100
print(f"Unexpected error during email verification for {email}: {str(e)}")
98101
return Response(status_code=500)
102+
103+
104+
@router.get("/me", response_model=UserCreateResponse)
105+
async def get_current_user(
106+
request: Request,
107+
credentials: HTTPAuthorizationCredentials = Depends(security),
108+
db: Session = Depends(get_db),
109+
):
110+
"""Get current authenticated user information including role"""
111+
try:
112+
# Get user auth_id from request state (set by auth middleware)
113+
user_auth_id = request.state.user_id
114+
if not user_auth_id:
115+
raise HTTPException(status_code=401, detail="Authentication required")
116+
117+
# Query user from database
118+
user = db.query(User).filter(User.auth_id == user_auth_id).first()
119+
if not user:
120+
raise HTTPException(status_code=404, detail="User not found")
121+
122+
return UserCreateResponse(
123+
id=user.id,
124+
first_name=user.first_name,
125+
last_name=user.last_name,
126+
email=user.email,
127+
role_id=user.role_id,
128+
auth_id=user.auth_id,
129+
approved=user.approved,
130+
)
131+
except HTTPException:
132+
raise
133+
except Exception as e:
134+
print(f"Error getting current user: {str(e)}")
135+
raise HTTPException(status_code=500, detail="Internal server error")

backend/app/utilities/db_utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66

77
load_dotenv()
88

9-
DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL")
9+
DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL")
10+
if not DATABASE_URL:
11+
raise RuntimeError(
12+
"POSTGRES_TEST_DATABASE_URL is not set. "
13+
"Set one of them to a valid Postgres URL, e.g. "
14+
"postgresql+psycopg2://postgres:postgres@db:5432/llsc_test"
15+
)
1016
engine = create_engine(DATABASE_URL)
1117
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
1218

backend/app/utilities/service_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ def get_user_service(db: Session = Depends(get_db)):
1212
return UserService(db)
1313

1414

15-
def get_auth_service(db: Session = Depends(get_db)):
15+
def get_auth_service(user_service: UserService = Depends(get_user_service)):
1616
logger = logging.getLogger(__name__)
17-
return AuthService(logger=logger, user_service=UserService(db))
17+
return AuthService(logger=logger, user_service=user_service)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
3+
4+
/**
5+
* Simple loading component for protected pages
6+
*/
7+
export const AuthLoadingSkeleton: React.FC = () => {
8+
return (
9+
<Flex minH="100vh" align="center" justify="center">
10+
<Box textAlign="center">
11+
<Spinner size="xl" color="blue.500" thickness="4px" />
12+
<Text fontSize="lg" color="gray.600" mt={4}>
13+
Loading...
14+
</Text>
15+
</Box>
16+
</Flex>
17+
);
18+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import { UserRole } from '@/types/authTypes';
3+
import { useProtectedRoute } from '@/hooks/useProtectedRoute';
4+
5+
interface ProtectedPageProps {
6+
allowedRoles: UserRole[];
7+
children: React.ReactNode;
8+
}
9+
10+
/**
11+
* Wrapper component that handles auth protection logic for pages
12+
* Eliminates the need to repeat auth checks in every protected page
13+
*/
14+
export const ProtectedPage: React.FC<ProtectedPageProps> = ({ allowedRoles, children }) => {
15+
const { authorized, LoadingComponent } = useProtectedRoute(allowedRoles);
16+
17+
// Show loading skeleton while checking auth
18+
if (LoadingComponent) {
19+
return <LoadingComponent />;
20+
}
21+
22+
// This will never be reached due to redirects in the hook, but good for safety
23+
if (!authorized) {
24+
return null;
25+
}
26+
27+
return <>{children}</>;
28+
};

frontend/src/components/intake/thank-you-screen.tsx

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { Box, Heading, Text, VStack } from '@chakra-ui/react';
3-
import { COLORS, IntakeFormData } from '@/constants/form';
3+
import { COLORS } from '@/constants/form';
44

55
// Check mark icon component
66
const CheckMarkIcon: React.FC = () => (
@@ -27,11 +27,7 @@ const CheckMarkIcon: React.FC = () => (
2727
</Box>
2828
);
2929

30-
interface ThankYouScreenProps {
31-
formData?: IntakeFormData;
32-
}
33-
34-
export function ThankYouScreen({ formData }: ThankYouScreenProps) {
30+
export function ThankYouScreen() {
3531
return (
3632
<Box
3733
minH="100vh"
@@ -80,36 +76,16 @@ export function ThankYouScreen({ formData }: ThankYouScreenProps) {
8076
fontSize="16px"
8177
color={COLORS.fieldGray}
8278
lineHeight="1.6"
83-
maxW="500px"
79+
maxW="600px"
8480
textAlign="center"
8581
>
86-
You will receive a confirmation email. A staff member will call you within 4-5 business
82+
You will receive a confirmation email. A staff member will call you within 1-2 business
8783
days to better understand your match preferences. For any inquiries, please reach us at{' '}
8884
<Text as="span" color={COLORS.teal} fontWeight={500}>
89-
85+
9086
</Text>
91-
.
87+
. Please note LLSC&apos;s working days are Monday-Thursday.
9288
</Text>
93-
94-
{/* Debug: Display form data */}
95-
{formData && (
96-
<Box mt={8} p={6} bg="gray.50" borderRadius="8px" w="full" textAlign="left">
97-
<Heading as="h3" fontSize="18px" fontWeight={600} color={COLORS.veniceBlue} mb={4}>
98-
Collected Form Data (Debug)
99-
</Heading>
100-
<Box
101-
as="pre"
102-
fontSize="12px"
103-
color={COLORS.fieldGray}
104-
fontFamily="monospace"
105-
whiteSpace="pre-wrap"
106-
overflow="auto"
107-
maxH="400px"
108-
>
109-
{JSON.stringify(formData, null, 2)}
110-
</Box>
111-
</Box>
112-
)}
11389
</VStack>
11490
</Box>
11591
</Box>

frontend/src/hooks/useAuthGuard.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { useEffect, useState, useMemo } from 'react';
2+
import { useRouter } from 'next/router';
3+
import { onAuthStateChanged, User } from 'firebase/auth';
4+
import { auth } from '@/config/firebase';
5+
import { UserRole } from '@/types/authTypes';
6+
import baseAPIClient from '@/APIClients/baseAPIClient';
7+
8+
interface AxiosError {
9+
response?: {
10+
status: number;
11+
data: unknown;
12+
};
13+
request?: unknown;
14+
message?: string;
15+
}
16+
17+
interface AuthGuardState {
18+
loading: boolean;
19+
authorized: boolean;
20+
}
21+
22+
// Map role IDs to UserRole enum
23+
const roleIdToUserRole = (roleId: number): UserRole | null => {
24+
switch (roleId) {
25+
case 1:
26+
return UserRole.PARTICIPANT;
27+
case 2:
28+
return UserRole.VOLUNTEER;
29+
case 3:
30+
return UserRole.ADMIN;
31+
default:
32+
return null;
33+
}
34+
};
35+
36+
/**
37+
* Hook to protect pages with authentication and role-based access control
38+
* @param allowedRoles - Array of roles that can access this page
39+
* @returns Object with loading and authorized states
40+
*/
41+
export const useAuthGuard = (allowedRoles: UserRole[]): AuthGuardState => {
42+
const router = useRouter();
43+
const [authState, setAuthState] = useState<AuthGuardState>({
44+
loading: true,
45+
authorized: false,
46+
});
47+
48+
// Memoize allowedRoles to prevent infinite re-renders
49+
const memoizedAllowedRoles = useMemo(() => allowedRoles, [allowedRoles]);
50+
51+
const getUserRole = async (user: User): Promise<UserRole | null> => {
52+
const cacheKey = `userRole_${user.uid}`;
53+
54+
// Check cache first
55+
try {
56+
const cached = sessionStorage.getItem(cacheKey);
57+
if (cached) {
58+
const { role, timestamp } = JSON.parse(cached);
59+
// Cache valid for 1 hour
60+
if (Date.now() - timestamp < 3600000) {
61+
return role;
62+
}
63+
// Remove expired cache
64+
sessionStorage.removeItem(cacheKey);
65+
}
66+
} catch {
67+
// If cache is corrupted, remove it and continue
68+
sessionStorage.removeItem(cacheKey);
69+
}
70+
71+
try {
72+
// Get the Firebase ID token
73+
const token = await user.getIdToken();
74+
// Call your backend to get user data with role
75+
const response = await baseAPIClient.get('/auth/me', {
76+
headers: {
77+
Authorization: `Bearer ${token}`,
78+
},
79+
});
80+
81+
// Convert roleId to UserRole enum (API client converts snake_case to camelCase)
82+
const userRole = roleIdToUserRole(response.data.roleId);
83+
84+
// Cache the result
85+
if (userRole) {
86+
sessionStorage.setItem(
87+
cacheKey,
88+
JSON.stringify({
89+
role: userRole,
90+
timestamp: Date.now(),
91+
}),
92+
);
93+
}
94+
95+
return userRole;
96+
} catch (error: unknown) {
97+
console.error('[useAuthGuard] Error fetching user role:', error);
98+
if (error && typeof error === 'object' && 'response' in error) {
99+
const axiosError = error as AxiosError;
100+
console.error('[useAuthGuard] API Error status:', axiosError.response?.status);
101+
console.error('[useAuthGuard] API Error data:', axiosError.response?.data);
102+
} else if (error && typeof error === 'object' && 'request' in error) {
103+
console.error('[useAuthGuard] No response received:', (error as AxiosError).request);
104+
} else if (error && typeof error === 'object' && 'message' in error) {
105+
console.error('[useAuthGuard] Request setup error:', (error as AxiosError).message);
106+
}
107+
console.error('[useAuthGuard] Full error object:', error);
108+
return null;
109+
}
110+
};
111+
112+
useEffect(() => {
113+
const unsubscribe = onAuthStateChanged(auth, async (user) => {
114+
try {
115+
if (!user) {
116+
// No authenticated user - redirect to login
117+
router.push('/');
118+
return;
119+
}
120+
121+
// Check if email is verified
122+
if (!user.emailVerified) {
123+
router.push(`/verify?email=${encodeURIComponent(user.email || '')}`);
124+
return;
125+
}
126+
127+
// Get user role from backend
128+
const userRole = await getUserRole(user);
129+
130+
if (!userRole) {
131+
// Could not get user role - redirect to login
132+
router.push('/');
133+
return;
134+
}
135+
136+
if (!memoizedAllowedRoles.includes(userRole)) {
137+
// User doesn't have required role - redirect to unauthorized
138+
router.push('/unauthorized');
139+
return;
140+
}
141+
142+
// User is authorized
143+
setAuthState({ loading: false, authorized: true });
144+
} catch (error) {
145+
console.error('Auth guard error:', error);
146+
router.push('/');
147+
}
148+
});
149+
150+
return () => unsubscribe();
151+
}, [router, memoizedAllowedRoles]);
152+
153+
return authState;
154+
};
155+
156+
/**
157+
* Clear all cached user role data from session storage
158+
* Call this function when the user logs out
159+
*/
160+
export const clearAuthCache = (): void => {
161+
try {
162+
Object.keys(sessionStorage).forEach((key) => {
163+
if (key.startsWith('userRole_')) {
164+
sessionStorage.removeItem(key);
165+
}
166+
});
167+
} catch (error) {
168+
// Session storage might not be available (e.g., in SSR or private browsing)
169+
console.warn('[useAuthGuard] Could not clear auth cache:', error);
170+
}
171+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
import { UserRole } from '@/types/authTypes';
3+
import { AuthLoadingSkeleton } from '@/components/auth/AuthLoadingSkeleton';
4+
import { useAuthGuard } from './useAuthGuard';
5+
6+
interface UseProtectedRouteResult {
7+
loading: boolean;
8+
authorized: boolean;
9+
LoadingComponent: React.ComponentType | null;
10+
}
11+
12+
/**
13+
* Hook that combines auth guarding with loading skeleton
14+
* Returns a LoadingComponent that you can render while auth is being checked
15+
*
16+
* @param allowedRoles - Array of roles that can access this page
17+
* @returns Object with loading state, authorized state, and LoadingComponent
18+
*/
19+
export const useProtectedRoute = (allowedRoles: UserRole[]): UseProtectedRouteResult => {
20+
const { loading, authorized } = useAuthGuard(allowedRoles);
21+
22+
const LoadingComponent = loading ? () => React.createElement(AuthLoadingSkeleton) : null;
23+
24+
return {
25+
loading,
26+
authorized,
27+
LoadingComponent,
28+
};
29+
};

0 commit comments

Comments
 (0)