diff --git a/backend/DOSPORTAL/settings.py b/backend/DOSPORTAL/settings.py index 1c82087..2ca8e1d 100644 --- a/backend/DOSPORTAL/settings.py +++ b/backend/DOSPORTAL/settings.py @@ -26,9 +26,11 @@ ) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv("DEBUG", "False") == "True" -ALLOWED_HOSTS = ["*", "localhost", "127.0.0.1", "0.0.0.0", "backend"] +ALLOWED_HOSTS = os.getenv( + "ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0,backend" +).split(",") # Site URL configuration SITE_URL = os.getenv("SITE_URL", "http://localhost:8080") diff --git a/backend/DOSPORTAL/tests/api/test_detector.py b/backend/DOSPORTAL/tests/api/test_detector.py index aa57490..7dd8304 100644 --- a/backend/DOSPORTAL/tests/api/test_detector.py +++ b/backend/DOSPORTAL/tests/api/test_detector.py @@ -174,3 +174,40 @@ def test_get_detectors_with_data(): assert response.status_code == 200 assert len(response.data) == 2 assert response.data[0]["name"] in ["Detector 1", "Detector 2"] + + +@pytest.mark.django_db +def test_get_detector_detail_success(): + """GET /detector// - returns single detector""" + user = User.objects.create_user(username="detailuser", password="pass123") + manuf = DetectorManufacturer.objects.create(name="Manuf", url="http://manuf.com") + dtype = DetectorType.objects.create(name="TypeA", manufacturer=manuf) + org = Organization.objects.create(name="OrgA") + detector = Detector.objects.create(name="Det1", type=dtype, owner=org, sn="SN-DETAIL") + + client = APIClient() + client.force_authenticate(user=user) + response = client.get(f"/api/detector/{detector.id}/") + assert response.status_code == 200 + assert response.data["id"] == str(detector.id) + assert response.data["name"] == "Det1" + assert response.data["sn"] == "SN-DETAIL" + + +@pytest.mark.django_db +def test_get_detector_detail_not_found(): + """GET /detector// - non-existent detector returns 404""" + user = User.objects.create_user(username="detailuser2", password="pass123") + client = APIClient() + client.force_authenticate(user=user) + response = client.get("/api/detector/00000000-0000-0000-0000-000000000000/") + assert response.status_code == 404 + assert "not found" in response.data["detail"].lower() + + +@pytest.mark.django_db +def test_get_detector_detail_unauthenticated(): + """GET /detector// - requires authentication""" + client = APIClient() + response = client.get("/api/detector/00000000-0000-0000-0000-000000000000/") + assert response.status_code in [401, 403] diff --git a/backend/api/permissions.py b/backend/api/permissions.py new file mode 100644 index 0000000..84c1aea --- /dev/null +++ b/backend/api/permissions.py @@ -0,0 +1,13 @@ +"""Shared permission helpers for API views.""" + +from DOSPORTAL.models import OrganizationUser + + +def check_org_admin_permission(user, org): + """ + Check if user is admin or owner of the organization. + Returns (has_permission: bool, org_user: OrganizationUser|None) + """ + org_user = OrganizationUser.objects.filter(user=user, organization=org).first() + has_permission = org_user and org_user.user_type in ["OW", "AD"] + return has_permission, org_user diff --git a/backend/api/urls.py b/backend/api/urls.py index 2ae26e0..7d10d55 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -15,6 +15,7 @@ path("measurement/add/", views.MeasurementsPost), path("record/", views.RecordGet), path("detector/", views.DetectorGet), + path("detector//", views.DetectorDetail), path("detector//qr/", views.DetectorQRCode), path("detector-manufacturer/", views.detector_manufacturer_list), path( diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py index 66b77c9..529a633 100644 --- a/backend/api/views/__init__.py +++ b/backend/api/views/__init__.py @@ -8,6 +8,7 @@ DetectorTypeList, DetectorTypeDetail, DetectorGet, + DetectorDetail, DetectorLogbookGet, DetectorLogbookPost, DetectorLogbookPut, @@ -46,6 +47,7 @@ "DetectorTypeList", "DetectorTypeDetail", "DetectorGet", + "DetectorDetail", "DetectorLogbookGet", "DetectorLogbookPost", "DetectorLogbookPut", diff --git a/backend/api/views/detectors.py b/backend/api/views/detectors.py index aa7bdd1..c70e472 100644 --- a/backend/api/views/detectors.py +++ b/backend/api/views/detectors.py @@ -24,22 +24,11 @@ DetectorLogbookSerializer, ) from ..qr_utils import generate_qr_code, generate_qr_detector_with_label +from ..permissions import check_org_admin_permission logger = logging.getLogger("api.detectors") -def check_org_admin_permission(user, org): - """ - Check if user is admin or owner of the organization. - Returns (has_permission: bool, org_user: OrganizationUser|None) - """ - from DOSPORTAL.models import OrganizationUser - - org_user = OrganizationUser.objects.filter(user=user, organization=org).first() - has_permission = org_user and org_user.user_type in ["OW", "AD"] - return has_permission, org_user - - @extend_schema( responses={200: DetectorManufacturerSerializer(many=True)}, request=DetectorManufacturerSerializer, @@ -200,6 +189,33 @@ def DetectorGet(request): ) +@extend_schema( + responses={200: DetectorSerializer}, + description="Get a single detector by ID", + tags=["Detectors"], + parameters=[ + OpenApiParameter( + name="detector_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + description="Detector ID", + ) + ], +) +@api_view(["GET"]) +@permission_classes((IsAuthenticated,)) +def DetectorDetail(request, detector_id): + """Get a single detector by ID.""" + try: + detector = Detector.objects.select_related("type__manufacturer", "owner").get( + id=detector_id + ) + except Detector.DoesNotExist: + return Response({"detail": "Detector not found."}, status=status.HTTP_404_NOT_FOUND) + serializer = DetectorSerializer(detector) + return Response(serializer.data) + + @extend_schema( responses={200: DetectorLogbookSerializer(many=True)}, description="Get detector logbook entries with optional filters by detector, type, or date range", @@ -285,7 +301,7 @@ def DetectorLogbookPost(request): ) except Detector.DoesNotExist: return Response( - {"detail": "Detektor not found."}, status=status.HTTP_404_NOT_FOUND + {"detail": "Detector not found."}, status=status.HTTP_404_NOT_FOUND ) serializer = DetectorLogbookSerializer(data=request.data) diff --git a/backend/api/views/measurements.py b/backend/api/views/measurements.py index 5448e2e..391742e 100644 --- a/backend/api/views/measurements.py +++ b/backend/api/views/measurements.py @@ -25,6 +25,7 @@ def MeasurementsPost(request): if serializer.is_valid(): serializer.save() return Response(serializer.data) + return Response(serializer.errors, status=400) @extend_schema(tags=["Measurements"]) diff --git a/backend/api/views/organizations.py b/backend/api/views/organizations.py index 72462ae..a1fef5f 100644 --- a/backend/api/views/organizations.py +++ b/backend/api/views/organizations.py @@ -26,22 +26,13 @@ CreateInviteRequestSerializer, CreateInviteResponseSerializer, ) +from ..permissions import check_org_admin_permission import logging logger = logging.getLogger(__name__) -def check_org_admin_permission(user, org): - """ - Check if user is admin or owner of the organization. - Returns (has_permission: bool, org_user: OrganizationUser|None) - """ - org_user = OrganizationUser.objects.filter(user=user, organization=org).first() - has_permission = org_user and org_user.user_type in ["OW", "AD"] - return has_permission, org_user - - @extend_schema( request=CreateOrganizationRequestSerializer, responses={201: OrganizationDetailSerializer}, @@ -76,7 +67,7 @@ def Organizations(request): user=request.user, organization=org, user_type="OW" ) serializer = OrganizationDetailSerializer(org) - logger.exception("Error creating organization") + logger.info("Organization created: %s by user %s", org.id, request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception: return Response( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 716f5cb..7f40bb2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "frontend", + "name": "dosportal-rect", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend", + "name": "dosportal-rect", "version": "0.0.0", "dependencies": { "leaflet": "^1.9.4", @@ -62,6 +62,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1033,6 +1034,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1042,6 +1044,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1107,6 +1110,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -1364,6 +1368,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1479,6 +1484,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1774,6 +1780,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2396,7 +2403,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -3484,6 +3492,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3555,6 +3564,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3564,6 +3574,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3943,6 +3954,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4145,6 +4157,7 @@ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -4267,6 +4280,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index e4c7e5f..3310971 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "dosportal-rect", + "name": "dosportal-react", "private": true, "version": "0.0.0", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9fd406..d0e18e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import './App.css' +import { AuthProvider, useAuthContext } from './context/AuthContext' +import { ProtectedRoute } from './components/ProtectedRoute' import { Layout } from './components/Layout' -import { useAuth } from './hooks/useAuth' import { HomePage } from './pages/HomePage' import { LoginPage } from './pages/LoginPage' import { SignupPage } from './pages/SignupPage' @@ -15,35 +16,47 @@ import { OrganizationDetailPage } from './pages/OrganizationDetailPage' import { InviteAcceptPage } from './pages/InviteAcceptPage'; import { DetectorCreatePage } from './pages/DetectorCreatePage'; -function App() { - const { API_BASE, ORIGIN_BASE, isAuthed, login, signup, logout, getAuthHeader } = useAuth() +function AppRoutes() { + const { ORIGIN_BASE, isAuthed, isLoading, login, signup, logout } = useAuthContext() + + if (isLoading) { + return null + } + return ( + + + : } + /> + : } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +function App() { return ( - - - : } - /> - : } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + ) } diff --git a/frontend/src/components/AddOrganizationMemberPopup.tsx b/frontend/src/components/AddOrganizationMemberPopup.tsx index 634069e..c5f21e4 100644 --- a/frontend/src/components/AddOrganizationMemberPopup.tsx +++ b/frontend/src/components/AddOrganizationMemberPopup.tsx @@ -1,21 +1,19 @@ import { useState, useEffect } from 'react'; import { theme } from '../theme'; +import { useAuthContext } from '../context/AuthContext'; export const AddOrganizationMemberPopup = ({ open, onClose, orgId, - apiBase, - getAuthHeader, onMemberAdded, }: { open: boolean; onClose: () => void; orgId: string; - apiBase: string; - getAuthHeader: () => { Authorization?: string }; onMemberAdded?: (username: string) => void; }) => { + const { API_BASE, getAuthHeader } = useAuthContext(); const [username, setUsername] = useState(''); const [userType, setUserType] = useState('ME'); const [inviteLink, setInviteLink] = useState(null); @@ -28,7 +26,7 @@ export const AddOrganizationMemberPopup = ({ useEffect(() => { if (!open) return; setOrgName(''); - fetch(`${apiBase}/organizations/${orgId}/`, { + fetch(`${API_BASE}/organizations/${orgId}/`, { headers: { ...getAuthHeader() }, }) .then(async (res) => { @@ -37,14 +35,14 @@ export const AddOrganizationMemberPopup = ({ }) .then((data) => setOrgName(data.name || '')) .catch(() => setOrgName('')); - }, [open, orgId, apiBase, getAuthHeader]); + }, [open, orgId, API_BASE, getAuthHeader]); const handleAdd = async () => { if (!username.trim()) return; setLoading(true); setError(null); try { - const res = await fetch(`${apiBase}/organizations/${orgId}/member/`, { + const res = await fetch(`${API_BASE}/organizations/${orgId}/member/`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ username: username.trim(), user_type: userType }), @@ -66,7 +64,7 @@ export const AddOrganizationMemberPopup = ({ setLoading(true); setError(null); try { - const res = await fetch(`${apiBase}/organizations/${orgId}/invites/`, { + const res = await fetch(`${API_BASE}/organizations/${orgId}/invites/`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ user_type: userType }), diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ebf0e6b --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,26 @@ +import { Navigate } from 'react-router-dom' +import { useAuthContext } from '../context/AuthContext' +import type { ReactNode } from 'react' + +interface ProtectedRouteProps { + children: ReactNode +} + +/** + * Wraps a route that requires authentication. + * While auth state is loading, renders nothing to avoid flash of content. + * Once resolved, redirects unauthenticated users to /login. + */ +export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const { isAuthed, isLoading } = useAuthContext() + + if (isLoading) { + return null + } + + if (!isAuthed) { + return + } + + return <>{children} +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..6a28131 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,38 @@ +import { createContext, useContext } from 'react' +import type { ReactNode } from 'react' +import { useAuth } from '../hooks/useAuth' + +interface AuthContextValue { + API_BASE: string + ORIGIN_BASE: string + isAuthed: boolean + isLoading: boolean + token: string | null + login: (username: string, password: string) => Promise + signup: ( + username: string, + firstName: string, + lastName: string, + password: string, + password_confirm: string, + email: string, + ) => Promise + logout: () => Promise + getAuthHeader: () => { Authorization?: string } +} + +const AuthContext = createContext(null) + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const auth = useAuth() + return {children} +} + +// eslint-disable-next-line react-refresh/only-export-components +export const useAuthContext = (): AuthContextValue => { + const ctx = useContext(AuthContext) + if (!ctx) { + throw new Error('useAuthContext must be used inside AuthProvider') + } + return ctx +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index fedab4f..05ff20f 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState, useEffect, useCallback } from 'react' export const useAuth = () => { const { API_BASE, ORIGIN_BASE } = useMemo(() => { @@ -97,9 +97,9 @@ export const useAuth = () => { } // Helper to get Authorization header for authenticated requests - const getAuthHeader = (): { Authorization?: string } => { + const getAuthHeader = useCallback((): { Authorization?: string } => { return token ? { Authorization: `Token ${token}` } : {} - } + }, [token]) return { API_BASE, diff --git a/frontend/src/pages/CreateOrganizationPage.tsx b/frontend/src/pages/CreateOrganizationPage.tsx index aa99747..217d666 100644 --- a/frontend/src/pages/CreateOrganizationPage.tsx +++ b/frontend/src/pages/CreateOrganizationPage.tsx @@ -3,17 +3,11 @@ import { useNavigate } from 'react-router-dom' import { PageLayout } from '../components/PageLayout' import { theme } from '../theme' import { LabeledInput } from '../components/LabeledInput' +import { useAuthContext } from '../context/AuthContext' import profileBg from '../assets/img/SPACEDOS01.jpg' -export const CreateOrganizationPage = ({ - apiBase, - isAuthed, - getAuthHeader, -}: { - apiBase: string - isAuthed: boolean - getAuthHeader: () => { Authorization?: string } -}) => { +export const CreateOrganizationPage = () => { + const { API_BASE, getAuthHeader } = useAuthContext() const navigate = useNavigate() const [name, setName] = useState('') const [dataPolicy, setDataPolicy] = useState('PU') @@ -41,7 +35,7 @@ export const CreateOrganizationPage = ({ } try { - const res = await fetch(`${apiBase}/organizations/`, { + const res = await fetch(`${API_BASE}/organizations/`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -76,18 +70,6 @@ export const CreateOrganizationPage = ({ } } - if (!isAuthed) { - return ( - -
-
- Login required to create organization. -
-
-
- ) - } - return (
diff --git a/frontend/src/pages/DetectorCreatePage.tsx b/frontend/src/pages/DetectorCreatePage.tsx index 0cd8586..7280d24 100644 --- a/frontend/src/pages/DetectorCreatePage.tsx +++ b/frontend/src/pages/DetectorCreatePage.tsx @@ -5,16 +5,10 @@ import { theme } from "../theme"; import { LabeledInput } from "../components/LabeledInput"; import logbookBg from "../assets/img/SPACEDOS01.jpg"; import { DetectorTypeInfo } from "../components/DetectorTypeInfo"; +import { useAuthContext } from "../context/AuthContext"; -export const DetectorCreatePage = ({ - apiBase, - isAuthed, - getAuthHeader, -}: { - apiBase: string; - isAuthed: boolean; - getAuthHeader: () => { Authorization?: string }; -}) => { +export const DetectorCreatePage = () => { + const { API_BASE, getAuthHeader } = useAuthContext(); const navigate = useNavigate(); const [sn, setSn] = useState(""); const [name, setName] = useState(""); @@ -27,15 +21,13 @@ export const DetectorCreatePage = ({ // Fetch detector type info when type changes useEffect(() => { - if (!isAuthed) return; // waiting for creadentials - if (!type) { setTypeInfo(null); return; } const fetchTypeInfo = async () => { try { - const res = await fetch(`${apiBase}/detector-type/${type}/`, { + const res = await fetch(`${API_BASE}/detector-type/${type}/`, { method: "GET", headers: { "Content-Type": "application/json", @@ -51,15 +43,13 @@ export const DetectorCreatePage = ({ }; fetchTypeInfo(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type, apiBase, isAuthed]); + }, [type, API_BASE]); useEffect(() => { - if (!isAuthed) return; // waiting for creadentials - // Fetch organizations where user is owner/admin const fetchOwnedOrgs = async () => { try { - const res = await fetch(`${apiBase}/user/organizations/owned/`, { + const res = await fetch(`${API_BASE}/user/organizations/owned/`, { method: "GET", headers: { "Content-Type": "application/json", @@ -86,7 +76,7 @@ export const DetectorCreatePage = ({ const fetchDetectorTypes = async () => { try { - const res = await fetch(`${apiBase}/detector-type/`, { + const res = await fetch(`${API_BASE}/detector-type/`, { method: "GET", headers: { "Content-Type": "application/json", @@ -110,7 +100,7 @@ export const DetectorCreatePage = ({ fetchDetectorTypes(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [apiBase, isAuthed]); + }, [API_BASE]); const [selectedAccess, setSelectedAccess] = useState< { value: string; label: string }[] @@ -140,7 +130,7 @@ export const DetectorCreatePage = ({ owner, access: selectedAccess.map((o) => o.value), }; - const res = await fetch(`${apiBase}/detector/`, { + const res = await fetch(`${API_BASE}/detector/`, { method: "POST", headers: { "Content-Type": "application/json", @@ -160,26 +150,6 @@ export const DetectorCreatePage = ({ } }; - if (!isAuthed) { - return ( - -
-
- Login required to add detector. -
-
-
- ); - } - - return ( { Authorization?: string } -}) => { +export const DetectorLogbookPage = () => { + const { API_BASE, getAuthHeader } = useAuthContext() const { id } = useParams<{ id: string }>() const navigate = useNavigate() const [detector, setDetector] = useState(null) @@ -46,14 +18,13 @@ export const DetectorLogbookPage = ({ const [error, setError] = useState(null) useEffect(() => { - if (!id || !isAuthed) return + if (!id) return const fetchDetectorAndLogbook = async () => { setLoading(true) setError(null) try { - // Fetch detector details - const detectorRes = await fetch(`${apiBase}/detector/`, { + const detectorRes = await fetch(`${API_BASE}/detector/${id}/`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -61,16 +32,10 @@ export const DetectorLogbookPage = ({ }, }) if (!detectorRes.ok) throw new Error(`HTTP ${detectorRes.status}`) - const detectors = await detectorRes.json() - const foundDetector = detectors.find((d: Detector) => d.id === id) - - if (!foundDetector) { - throw new Error('Detector not found') - } - setDetector(foundDetector) + setDetector(await detectorRes.json()) // Fetch logbook entries - const logbookRes = await fetch(`${apiBase}/logbook/?detector=${id}`, { + const logbookRes = await fetch(`${API_BASE}/logbook/?detector=${id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -78,8 +43,7 @@ export const DetectorLogbookPage = ({ }, }) if (!logbookRes.ok) throw new Error(`HTTP ${logbookRes.status}`) - const logbookData = await logbookRes.json() - setLogbook(logbookData) + setLogbook(await logbookRes.json()) } catch (e: any) { setError(`Failed to load detector logbook: ${e.message}`) } finally { @@ -88,19 +52,7 @@ export const DetectorLogbookPage = ({ } fetchDetectorAndLogbook() - }, [id, apiBase, isAuthed]) - - if (!isAuthed) { - return ( - -
-
- Login required to view logbook. -
-
-
- ) - } + }, [id, API_BASE, getAuthHeader]) return ( @@ -140,7 +92,7 @@ export const DetectorLogbookPage = ({ onClick={async () => { try { const res = await fetch( - `${apiBase}/detector/${detector.id}/qr/?label=true`, + `${API_BASE}/detector/${detector.id}/qr/?label=true`, { method: 'GET', headers: { diff --git a/frontend/src/pages/InviteAcceptPage.tsx b/frontend/src/pages/InviteAcceptPage.tsx index 2525d4e..30c35a9 100644 --- a/frontend/src/pages/InviteAcceptPage.tsx +++ b/frontend/src/pages/InviteAcceptPage.tsx @@ -2,20 +2,12 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { PageLayout } from '../components/PageLayout'; import { theme } from '../theme'; +import { useAuthContext } from '../context/AuthContext'; - -import { useAuth } from '../hooks/useAuth'; - -export const InviteAcceptPage = ({ - apiBase, - getAuthHeader, -}: { - apiBase: string; - getAuthHeader: () => { Authorization?: string }; -}) => { +export const InviteAcceptPage = () => { const { token } = useParams<{ token: string }>(); const navigate = useNavigate(); - const { isAuthed, isLoading } = useAuth(); + const { API_BASE, isAuthed, isLoading, getAuthHeader } = useAuthContext(); const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [error, setError] = useState(null); const [successMsg, setSuccessMsg] = useState(null); @@ -26,7 +18,7 @@ export const InviteAcceptPage = ({ useEffect(() => { if (!token) return; setInviteLoading(true); - fetch(`${apiBase}/invites/${token}/`) + fetch(`${API_BASE}/invites/${token}/`) .then(async (res) => { const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Invalid invite'); @@ -37,7 +29,7 @@ export const InviteAcceptPage = ({ setError(e.message); setInviteLoading(false); }); - }, [apiBase, token]); + }, [API_BASE, token]); useEffect(() => { if (isLoading) return; // Wait until auth state is known @@ -52,7 +44,7 @@ export const InviteAcceptPage = ({ setStatus('loading'); setError(null); try { - const res = await fetch(`${apiBase}/invites/${token}/accept/`, { + const res = await fetch(`${API_BASE}/invites/${token}/accept/`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, }); diff --git a/frontend/src/pages/LogbookEntryPage.tsx b/frontend/src/pages/LogbookEntryPage.tsx index 858e865..af67a5c 100644 --- a/frontend/src/pages/LogbookEntryPage.tsx +++ b/frontend/src/pages/LogbookEntryPage.tsx @@ -3,26 +3,12 @@ import { useParams, useNavigate, Link } from 'react-router-dom' import { PageLayout } from '../components/PageLayout' import { LocationSearchMap } from '../components/LocationSearchMap' import { theme } from '../theme' +import { useAuthContext } from '../context/AuthContext' +import type { Detector } from '../types' import logbookBg from '../assets/img/SPACEDOS01.jpg' -interface Detector { - id: string - name: string - sn: string - type: { - name: string - } -} - -export const LogbookEntryPage = ({ - apiBase, - isAuthed, - getAuthHeader, -}: { - apiBase: string - isAuthed: boolean - getAuthHeader: () => { Authorization?: string } -}) => { +export const LogbookEntryPage = () => { + const { API_BASE, getAuthHeader } = useAuthContext() const { id, entryId } = useParams<{ id: string; entryId?: string }>() const navigate = useNavigate() const [detector, setDetector] = useState(null) @@ -52,14 +38,13 @@ export const LogbookEntryPage = ({ ] useEffect(() => { - if (!id || !isAuthed) return + if (!id) return const fetchData = async () => { setLoading(true) setError(null) try { - // Fetch detector details - const detectorRes = await fetch(`${apiBase}/detector/`, { + const detectorRes = await fetch(`${API_BASE}/detector/${id}/`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -67,17 +52,11 @@ export const LogbookEntryPage = ({ }, }) if (!detectorRes.ok) throw new Error(`HTTP ${detectorRes.status}`) - const detectors = await detectorRes.json() - const foundDetector = detectors.find((d: Detector) => d.id === id) - - if (!foundDetector) { - throw new Error('Detector not found') - } - setDetector(foundDetector) + setDetector(await detectorRes.json()) // If in edit mode, fetch the logbook entry if (isEditMode && entryId) { - const logbookRes = await fetch(`${apiBase}/logbook/?detector=${id}`, { + const logbookRes = await fetch(`${API_BASE}/logbook/?detector=${id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -108,7 +87,7 @@ export const LogbookEntryPage = ({ } fetchData() - }, [id, entryId, isEditMode, apiBase, isAuthed]) + }, [id, entryId, isEditMode, API_BASE, getAuthHeader]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -134,8 +113,8 @@ export const LogbookEntryPage = ({ const url = isEditMode - ? `${apiBase}/logbook/${entryId}/` - : `${apiBase}/logbook/add/` + ? `${API_BASE}/logbook/${entryId}/` + : `${API_BASE}/logbook/add/` const method = isEditMode ? 'PUT' : 'POST' @@ -181,18 +160,6 @@ export const LogbookEntryPage = ({ } } - if (!isAuthed) { - return ( - -
theme.colors.danger, padding: theme.spacing['3xl'] -
- Login required to create logbook entry. -
-
-
- ) - } - return (
diff --git a/frontend/src/pages/LogbooksPage.tsx b/frontend/src/pages/LogbooksPage.tsx index ede2995..3767fd7 100644 --- a/frontend/src/pages/LogbooksPage.tsx +++ b/frontend/src/pages/LogbooksPage.tsx @@ -6,37 +6,19 @@ import { Section } from '../components/Section' import { CardGrid } from '../components/CardGrid' import { EmptyState } from '../components/EmptyState' import { theme } from '../theme' +import { useAuthContext } from '../context/AuthContext' +import type { Detector } from '../types' import logbookBg from '../assets/img/SPACEDOS01.jpg' -interface Detector { - id: string - name: string - sn: string - type: { - name: string - manufacturer: { - name: string - } - } -} - -export const LogbooksPage = ({ - apiBase, - isAuthed, - getAuthHeader, -}: { - apiBase: string - isAuthed: boolean - getAuthHeader: () => { Authorization?: string } -}) => { +export const LogbooksPage = () => { + const { API_BASE, getAuthHeader } = useAuthContext() const [detectors, setDetectors] = useState([]) const [error, setError] = useState(null) useEffect(() => { - if (!isAuthed) return const fetchDetectors = async () => { try { - const res = await fetch(`${apiBase}/detector/`, { + const res = await fetch(`${API_BASE}/detector/`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -51,19 +33,7 @@ export const LogbooksPage = ({ } } fetchDetectors() - }, [apiBase, isAuthed]) - - if (!isAuthed) { - return ( - -
-
- Login required to view logbooks. -
-
-
- ) - } + }, [API_BASE, getAuthHeader]) return ( diff --git a/frontend/src/pages/OrganizationDetailPage.tsx b/frontend/src/pages/OrganizationDetailPage.tsx index 9ea89eb..75a8aaa 100644 --- a/frontend/src/pages/OrganizationDetailPage.tsx +++ b/frontend/src/pages/OrganizationDetailPage.tsx @@ -5,6 +5,7 @@ import { theme } from '../theme'; import profileBg from '../assets/img/SPACEDOS01.jpg'; import { FormField } from '../components/FormField'; import { AddOrganizationMemberPopup } from '../components/AddOrganizationMemberPopup'; +import { useAuthContext } from '../context/AuthContext'; type Member = { id: number; @@ -14,15 +15,8 @@ type Member = { user_type: 'OW' | 'AD' | 'ME'; }; -export const OrganizationDetailPage = ({ - apiBase, - isAuthed, - getAuthHeader, -}: { - apiBase: string; - isAuthed: boolean; - getAuthHeader: () => { Authorization?: string }; -}) => { +export const OrganizationDetailPage = () => { + const { API_BASE, getAuthHeader } = useAuthContext(); const { id } = useParams(); const [org, setOrg] = useState(null); const [error, setError] = useState(null); @@ -37,7 +31,7 @@ export const OrganizationDetailPage = ({ const fetchOrg = async () => { setLoading(true); try { - const res = await fetch(`${apiBase}/organizations/${id}/`, { + const res = await fetch(`${API_BASE}/organizations/${id}/`, { headers: { ...getAuthHeader() }, }); if (!res.ok) throw new Error((await res.json()).detail || 'Error'); @@ -63,7 +57,7 @@ export const OrganizationDetailPage = ({ try { const member = org.members.find((m: Member) => m.id === memberId); if (!member) throw new Error('Member not found'); - const res = await fetch(`${apiBase}/organizations/${id}/member/`, { + const res = await fetch(`${API_BASE}/organizations/${id}/member/`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ username: member.username, user_type: newRole }), @@ -86,7 +80,7 @@ export const OrganizationDetailPage = ({ try { const member = org.members.find((m: Member) => m.id === memberId); if (!member) throw new Error('Member not found'); - const res = await fetch(`${apiBase}/organizations/${id}/member/`, { + const res = await fetch(`${API_BASE}/organizations/${id}/member/`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ username: member.username }), @@ -104,13 +98,12 @@ export const OrganizationDetailPage = ({ }; useEffect(() => { - if (!isAuthed) return; fetchOrg(); - }, [id, apiBase, isAuthed, getAuthHeader]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, API_BASE, getAuthHeader]); useEffect(() => { - if (!isAuthed) return; - fetch(`${apiBase}/user/organizations/`, { + fetch(`${API_BASE}/user/organizations/`, { headers: { ...getAuthHeader() }, }) .then(async (res) => { @@ -119,7 +112,7 @@ export const OrganizationDetailPage = ({ }) .then(setUserOrgs) .catch(() => {}); - }, [apiBase, isAuthed, getAuthHeader]); + }, [API_BASE, getAuthHeader]); @@ -128,7 +121,7 @@ export const OrganizationDetailPage = ({ setSaveError(null); setSuccessMsg(null); try { - const res = await fetch(`${apiBase}/organizations/${id}/`, { + const res = await fetch(`${API_BASE}/organizations/${id}/`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ [field]: value }), @@ -150,18 +143,6 @@ export const OrganizationDetailPage = ({ // Determine if user is owner/admin of this org const readOnly = !userOrgs.some((o) => String(o.id) === String(id) && (o.user_type === 'OW' || o.user_type === 'AD')); - if (!isAuthed) { - return ( - -
-
- Login required to view organization. -
-
-
- ); - } - return ( {/* Fixed position success message */} @@ -413,8 +394,6 @@ export const OrganizationDetailPage = ({ setShowAddMember(false)} onMemberAdded={async (username: string) => { setSuccessMsg(`Member ${username} added successfully!`); diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index e23ca1e..1c97f7e 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -6,6 +6,8 @@ import { Section } from '../components/Section' import { CardGrid } from '../components/CardGrid' import { EmptyState } from '../components/EmptyState' import { theme } from '../theme' +import { useAuthContext } from '../context/AuthContext' +import type { Detector } from '../types' import profileBg from '../assets/img/SPACEDOS01.jpg' interface UserProfile { @@ -23,28 +25,8 @@ interface Organization { data_policy: string } -interface Detector { - id: string - name: string - sn: string - type: { - name: string - manufacturer: { - name: string - } - } -} - -export const ProfilePage = ({ - apiBase, - isAuthed, - getAuthHeader, -}: { - apiBase: string - originBase: string - isAuthed: boolean - getAuthHeader: () => { Authorization?: string } -}) => { +export const ProfilePage = () => { + const { API_BASE, getAuthHeader } = useAuthContext() const [profile, setProfile] = useState(null) const [organizations, setOrganizations] = useState([]) const [detectors, setDetectors] = useState([]) @@ -55,12 +37,10 @@ export const ProfilePage = ({ // Fetch user profile and related data useEffect(() => { - if (!isAuthed) return - const fetchData = async () => { try { // Fetch user profile - const profileRes = await fetch(`${apiBase}/user/profile/`, { + const profileRes = await fetch(`${API_BASE}/user/profile/`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -72,7 +52,7 @@ export const ProfilePage = ({ setProfile(profileData) // Fetch organizations - const orgsRes = await fetch(`${apiBase}/user/organizations/`, { + const orgsRes = await fetch(`${API_BASE}/user/organizations/`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -85,7 +65,7 @@ export const ProfilePage = ({ } // Fetch detectors - const detectorsRes = await fetch(`${apiBase}/detector/`, { + const detectorsRes = await fetch(`${API_BASE}/detector/`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -98,7 +78,7 @@ export const ProfilePage = ({ } // Fetch measurements count - const measurementsRes = await fetch(`${apiBase}/measurement/`, { + const measurementsRes = await fetch(`${API_BASE}/measurement/`, { method: 'GET', credentials: 'include', }) @@ -112,7 +92,7 @@ export const ProfilePage = ({ } fetchData() - }, [apiBase, isAuthed]) + }, [API_BASE, getAuthHeader]) const handleSaveField = async (field: 'email' | 'first_name' | 'last_name', value: string) => { setIsSaving(true) @@ -130,7 +110,7 @@ export const ProfilePage = ({ } try { - const res = await fetch(`${apiBase}/user/profile/`, { + const res = await fetch(`${API_BASE}/user/profile/`, { method: 'PUT', headers: { @@ -161,18 +141,6 @@ export const ProfilePage = ({ } } - if (!isAuthed) { - return ( - -
-
- Login required to view profile. -
-
-
- ) - } - if (!profile) { return ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index baa29fa..f3e12ce 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -22,3 +22,31 @@ export type LogbookItem = { longitude?: number location_text?: string } + +export type DetectorManufacturer = { + id: string + name: string + url?: string +} + +export type DetectorType = { + id: string + name: string + manufacturer: DetectorManufacturer + url?: string + description?: string +} + +export type Detector = { + id: string + name: string + sn: string + type: DetectorType + owner?: { + id: string + name: string + slug: string + } + manufactured_date?: string + data?: Record +}