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 ( +
{message}
{error.message}
+ if (status === 'loading') return
+ + {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. +
+ +