diff --git a/backend/app/services/user.py b/backend/app/services/user.py index 6550463..1b44d17 100644 --- a/backend/app/services/user.py +++ b/backend/app/services/user.py @@ -148,6 +148,8 @@ async def get_or_link_user(db: Session, sub: str, access_token: str) -> User: user = user_repo.get_by_auth0_sub(db, sub) if user is not None: + if not user.active: + raise LookupError(f"User account for {user.email} is deactivated. Contact an admin.") return user # First login: look up email from Auth0 userinfo @@ -163,8 +165,8 @@ async def get_or_link_user(db: Session, sub: str, access_token: str) -> User: raise ValueError("Could not retrieve email from Auth0 userinfo") user = user_repo.get_by_email(db, email) - if user is None: - raise LookupError(f"No user record found for {email}. Contact an admin.") + if user is None or not user.active: + raise LookupError(f"No active user record found for {email}. Contact an admin.") user_repo.set_auth0_sub(db, user, sub) return user diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9539f45..9a9486a 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,10 +1,14 @@ """Tests for Auth0 authentication middleware.""" +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from fastapi.testclient import TestClient from app.core.database import Base, get_db from app.main import app +from app.models.user import User +from app.services.user import get_or_link_user from tests.conftest import TestingSessionLocal, engine @@ -77,3 +81,86 @@ def test_get_current_user_returns_claims(client): response = client.get("/courses") assert response.status_code != 401 assert response.status_code != 403 + + +# --------------------------------------------------------------------------- +# get_or_link_user: inactive users are blocked +# --------------------------------------------------------------------------- + + +def _mock_userinfo(email: str): + """Build a patch for httpx.AsyncClient returning the given email from /userinfo.""" + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.json = MagicMock(return_value={"email": email}) + + client_mock = MagicMock() + client_mock.__aenter__ = AsyncMock(return_value=client_mock) + client_mock.__aexit__ = AsyncMock(return_value=None) + client_mock.get = AsyncMock(return_value=resp) + + return patch("httpx.AsyncClient", return_value=client_mock) + + +async def test_get_or_link_user_blocks_inactive_user_on_first_login(db_session): + """First login by an inactive user should raise LookupError.""" + db_session.add( + User( + nuid=12345, + first_name="Inactive", + last_name="User", + email="inactive@example.com", + role="VIEWER", + auth0_sub=None, + active=False, + ) + ) + db_session.commit() + + with _mock_userinfo("inactive@example.com"): + with pytest.raises(LookupError): + await get_or_link_user(db_session, sub="auth0|new-sub", access_token="token") + + refreshed = db_session.query(User).filter(User.email == "inactive@example.com").first() + assert refreshed.auth0_sub is None, "inactive user should not be linked" + + +async def test_get_or_link_user_blocks_inactive_user_on_subsequent_request(db_session): + """A user whose auth0_sub is already linked but who is now inactive should be blocked.""" + db_session.add( + User( + nuid=67890, + first_name="Deactivated", + last_name="User", + email="deactivated@example.com", + role="VIEWER", + auth0_sub="auth0|linked-sub", + active=False, + ) + ) + db_session.commit() + + with pytest.raises(LookupError): + await get_or_link_user(db_session, sub="auth0|linked-sub", access_token="token") + + +async def test_get_or_link_user_allows_active_user_on_first_login(db_session): + """First login by an active user should link the sub and return the user.""" + db_session.add( + User( + nuid=11111, + first_name="Active", + last_name="User", + email="active@example.com", + role="VIEWER", + auth0_sub=None, + active=True, + ) + ) + db_session.commit() + + with _mock_userinfo("active@example.com"): + user = await get_or_link_user(db_session, sub="auth0|fresh-sub", access_token="token") + + assert user.email == "active@example.com" + assert user.auth0_sub == "auth0|fresh-sub" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63d7eda..5dabb19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,35 +6,65 @@ import Faculty from './pages/Faculty'; import Courses from './pages/Courses'; import Sidebar from './components/Sidebar'; import LoginButton from './components/LoginButton'; +import AccessDenied from './components/AccessDenied'; import { useAuthInterceptor } from './hooks/useAuthInterceptor'; -import { UserProvider } from './context/UserContext'; +import { UserProvider, useUser } from './context/UserContext'; -function App() { - const { isAuthenticated, isLoading, error } = useAuth0(); - useAuthInterceptor(); +function LoadingScreen() { + return ( +
+
+
+ Loading... +
+
+ ); +} - if (isLoading) { - return ( -
-
-
- Loading... -
+function ErrorScreen({ message }: { message: string }) { + return ( +
+
+
⚠️
+

Something went wrong

+

{message}

- ); - } +
+ ); +} + +function AuthorizedApp() { + const { status, errorMessage } = useUser(); - if (error) { - return ( -
-
-
⚠️
-

Something went wrong

-

{error.message}

+ if (status === 'loading') return ; + if (status === 'blocked') return ; + if (status === 'error') return ; + + return ( +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> +
-
- ); - } + +
+ ); +} + +function App() { + const { isAuthenticated, isLoading, error } = useAuth0(); + useAuthInterceptor(); + + if (isLoading) return ; + if (error) return ; return ( @@ -58,21 +88,7 @@ function App() {
) : ( -
- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
+
)} diff --git a/frontend/src/components/AccessDenied.tsx b/frontend/src/components/AccessDenied.tsx new file mode 100644 index 0000000..bef1f81 --- /dev/null +++ b/frontend/src/components/AccessDenied.tsx @@ -0,0 +1,46 @@ +import { useAuth0 } from '@auth0/auth0-react'; + +const AccessDenied = () => { + const { user, logout } = useAuth0(); + + const handleLogout = () => { + logout({ logoutParams: { returnTo: window.location.origin } }); + }; + + return ( +
+
+
+ ACS Automated Course Scheduler +

Access denied

+
+
+

+ {user?.email ? ( + <> + {user.email} is not authorized to use this application. + + ) : ( + 'Your account is not authorized to use this application.' + )} +

+

+ Contact an administrator if you believe this is a mistake. +

+ +
+
+
+ ); +}; + +export default AccessDenied; diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx index 62e3451..7fe57e6 100644 --- a/frontend/src/context/UserContext.tsx +++ b/frontend/src/context/UserContext.tsx @@ -2,37 +2,61 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { getAutomatedCourseSchedulerAPI, type UserResponse } from '../api/generated'; +export type UserStatus = 'loading' | 'authorized' | 'blocked' | 'error'; + interface UserContextValue { me: UserResponse | null; + status: UserStatus; + errorMessage: string | null; meError: string | null; meLoading: boolean; } -const UserContext = createContext({ me: null, meError: null, meLoading: true }); +const UserContext = createContext({ + me: null, + status: 'loading', + errorMessage: null, + meError: null, + meLoading: true, +}); export function UserProvider({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth0(); const [me, setMe] = useState(null); - const [meError, setMeError] = useState(null); - const [meLoading, setMeLoading] = useState(true); + const [status, setStatus] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(null); useEffect(() => { if (!isAuthenticated) return; getAutomatedCourseSchedulerAPI() .getMeApiUsersMeGet() - .then(setMe) - .catch((err: unknown) => { - const status = (err as { response?: { status?: number } })?.response?.status; - setMeError( - status === 403 - ? 'Your Auth0 account is not linked to a DB user yet. Ask an admin to invite you or run bootstrap_admin.py.' - : 'Could not load your user profile.', - ); + .then((user) => { + setMe(user); + setStatus('authorized'); }) - .finally(() => setMeLoading(false)); + .catch((err: unknown) => { + const httpStatus = (err as { response?: { status?: number } })?.response?.status; + if (httpStatus === 403) { + setStatus('blocked'); + setErrorMessage(null); + } else { + setStatus('error'); + setErrorMessage('Could not load your user profile.'); + } + }); }, [isAuthenticated]); - return {children}; + const value: UserContextValue = { + me, + status, + errorMessage, + meError: status === 'blocked' + ? 'Your account is not authorized to use this application.' + : errorMessage, + meLoading: status === 'loading', + }; + + return {children}; } export function useUser() {