Skip to content
Merged
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
6 changes: 4 additions & 2 deletions backend/app/services/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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"
92 changes: 54 additions & 38 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-gray-500">
<div className="w-8 h-8 border-4 border-burgundy-200 border-t-burgundy-600 rounded-full animate-spin" />
<span className="text-sm font-medium">Loading...</span>
</div>
</div>
);
}

if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-gray-500">
<div className="w-8 h-8 border-4 border-burgundy-200 border-t-burgundy-600 rounded-full animate-spin" />
<span className="text-sm font-medium">Loading...</span>
</div>
function ErrorScreen({ message }: { message: string }) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-sm border border-red-100 p-8 max-w-sm w-full text-center">
<div className="text-3xl mb-2">⚠️</div>
<h2 className="text-lg font-semibold text-gray-900 mb-1">Something went wrong</h2>
<p className="text-sm text-gray-500">{message}</p>
</div>
);
}
</div>
);
}

function AuthorizedApp() {
const { status, errorMessage } = useUser();

if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-sm border border-red-100 p-8 max-w-sm w-full text-center">
<div className="text-3xl mb-2">⚠️</div>
<h2 className="text-lg font-semibold text-gray-900 mb-1">Something went wrong</h2>
<p className="text-sm text-gray-500">{error.message}</p>
if (status === 'loading') return <LoadingScreen />;
if (status === 'blocked') return <AccessDenied />;
if (status === 'error') return <ErrorScreen message={errorMessage ?? 'Could not load your user profile.'} />;

return (
<div className="flex h-screen overflow-hidden bg-gray-50">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<div className="p-8">
<Routes>
<Route path="/" element={<Navigate to="/schedules" replace />} />
<Route path="/schedules" element={<ScheduleList />} />
<Route path="/schedules/:scheduleId" element={<Schedules />} />
<Route path="/faculty/schedules/:scheduleId" element={<Schedules readOnly />} />
<Route path="/faculty" element={<Faculty />} />
<Route path="/courses" element={<Courses />} />
</Routes>
</div>
</div>
);
}
</main>
</div>
);
}

function App() {
const { isAuthenticated, isLoading, error } = useAuth0();
useAuthInterceptor();

if (isLoading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error.message} />;

return (
<BrowserRouter>
Expand All @@ -58,21 +88,7 @@ function App() {
</div>
) : (
<UserProvider>
<div className="flex h-screen overflow-hidden bg-gray-50">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<div className="p-8">
<Routes>
<Route path="/" element={<Navigate to="/schedules" replace />} />
<Route path="/schedules" element={<ScheduleList />} />
<Route path="/schedules/:scheduleId" element={<Schedules />} />
<Route path="/faculty/schedules/:scheduleId" element={<Schedules readOnly />} />
<Route path="/faculty" element={<Faculty />} />
<Route path="/courses" element={<Courses />} />
</Routes>
</div>
</main>
</div>
<AuthorizedApp />
</UserProvider>
)}
</BrowserRouter>
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/components/AccessDenied.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-burgundy-50 via-white to-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img
src="/acs-logo.png"
alt="ACS Automated Course Scheduler"
className="h-16 w-auto max-w-full mx-auto mb-4 object-contain drop-shadow-sm"
/>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Access denied</h1>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
<p className="text-gray-700 text-sm text-center mb-2">
{user?.email ? (
<>
<span className="font-medium text-gray-900">{user.email}</span> is not authorized to use this application.
</>
) : (
'Your account is not authorized to use this application.'
)}
</p>
<p className="text-gray-500 text-sm text-center mb-6">
Contact an administrator if you believe this is a mistake.
</p>
<button
onClick={handleLogout}
className="w-full bg-burgundy-600 hover:bg-burgundy-700 active:bg-burgundy-800 text-white font-semibold py-2.5 px-4 rounded-xl transition-colors duration-150 shadow-sm"
>
Log out
</button>
</div>
</div>
</div>
);
};

export default AccessDenied;
50 changes: 37 additions & 13 deletions frontend/src/context/UserContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,63 @@
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<UserContextValue>({ me: null, meError: null, meLoading: true });
const UserContext = createContext<UserContextValue>({
me: null,
status: 'loading',
errorMessage: null,
meError: null,
meLoading: true,
});

export function UserProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth0();
const [me, setMe] = useState<UserResponse | null>(null);
const [meError, setMeError] = useState<string | null>(null);
const [meLoading, setMeLoading] = useState(true);
const [status, setStatus] = useState<UserStatus>('loading');
const [errorMessage, setErrorMessage] = useState<string | null>(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 <UserContext.Provider value={{ me, meError, meLoading }}>{children}</UserContext.Provider>;
const value: UserContextValue = {
me,
status,
errorMessage,
meError: status === 'blocked'
? 'Your account is not authorized to use this application.'
: errorMessage,
meLoading: status === 'loading',
};

return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

export function useUser() {

Check warning on line 62 in frontend/src/context/UserContext.tsx

View workflow job for this annotation

GitHub Actions / Lint & Test

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
return useContext(UserContext);
}
Loading