diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 4b40294b..b8e195cf 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -31,6 +31,9 @@ SQL_PORT=5432 # SQL_SSL_MODE=require LOGIN_REDIRECT_URL= +CORS_ALLOWED_ORIGINS=http://localhost:3000 +# Domain used by Djoser for activation and password reset email links (should be the frontend URL) +FRONTEND_DOMAIN=localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3f8585f0..edc044b0 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -19,6 +19,9 @@ export const AUTH_ENDPOINTS = { USER_ME: `${API_BASE}/auth/users/me/`, RESET_PASSWORD: `${API_BASE}/auth/users/reset_password/`, RESET_PASSWORD_CONFIRM: `${API_BASE}/auth/users/reset_password_confirm/`, + USERS_CREATE: `${API_BASE}/auth/users/`, + USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, + USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, } as const; /** diff --git a/frontend/src/components/ProtectedRoute/AdminRoute.tsx b/frontend/src/components/ProtectedRoute/AdminRoute.tsx new file mode 100644 index 00000000..61195cb8 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/AdminRoute.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; + +interface AdminRouteProps { + children: ReactNode; +} + +const AdminRoute = ({ children }: AdminRouteProps) => { + const location = useLocation(); + const dispatch = useDispatch(); + const { isAuthenticated, isSuperuser } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + + if (isAuthenticated === null) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (!isSuperuser) { + return ; + } + + return children; +}; + +export default AdminRoute; diff --git a/frontend/src/pages/Activate/Activate.tsx b/frontend/src/pages/Activate/Activate.tsx new file mode 100644 index 00000000..391ec04b --- /dev/null +++ b/frontend/src/pages/Activate/Activate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { verify, AppDispatch } from "../../services/actions/auth"; +import Layout from "../Layout/Layout"; +import Spinner from "../../components/LoadingSpinner/LoadingSpinner"; + +const Activate = () => { + const { uid, token } = useParams<{ uid: string; token: string }>(); + const dispatch = useDispatch(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + if (!uid || !token) { + setStatus("error"); + return; + } + + (async () => { + try { + await dispatch(verify(uid, token)); + setStatus("success"); + } catch { + setStatus("error"); + } + })(); + }, [dispatch, uid, token]); + + if (status === "loading") { + return ( + + + + ); + } + + if (status === "error") { + return ( + +
+
+

+ Activation failed +

+

+ This activation link is invalid or has already been used. Please register again or request a new activation email. +

+ + Back to register + +
+
+
+ ); + } + + return ( + +
+
+

+ Email verified +

+

+ Your account has been activated. You can now log in. +

+ + Continue to log in + +
+
+
+ ); +}; + +export default Activate; diff --git a/frontend/src/pages/Register/RegistrationForm.tsx b/frontend/src/pages/Register/RegistrationForm.tsx index c1745b3d..8134c521 100644 --- a/frontend/src/pages/Register/RegistrationForm.tsx +++ b/frontend/src/pages/Register/RegistrationForm.tsx @@ -1,71 +1,211 @@ import { useFormik } from "formik"; +import * as Yup from "yup"; import { Link } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { signup, AppDispatch } from "../../services/actions/auth"; +import { RootState } from "../../services/actions/types"; +import { useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; + +const validationSchema = Yup.object({ + first_name: Yup.string().required("First name is required"), + last_name: Yup.string().required("Last name is required"), + email: Yup.string().email("Enter a valid email").required("Email is required"), + password: Yup.string() + .min(8, "Password must be at least 8 characters") + .required("Password is required"), + re_password: Yup.string() + .oneOf([Yup.ref("password")], "Passwords must match") + .required("Please confirm your password"), +}); + +const RegistrationForm = () => { + const dispatch = useDispatch(); + const signupError = useSelector((state: RootState) => state.auth.error); + const [submitted, setSubmitted] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); + + const { handleSubmit, handleChange, handleBlur, values, errors, touched, isSubmitting } = + useFormik({ + initialValues: { + first_name: "", + last_name: "", + email: "", + password: "", + re_password: "", + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch(signup(values.first_name, values.last_name, values.email, values.password, values.re_password)); + setSubmittedEmail(values.email); + setSubmitted(true); + } catch { + // error is stored in Redux state and displayed via signupError + } finally { + setSubmitting(false); + } + }, + }); + + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.USERS_RESEND_ACTIVATION, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + + if (submitted) { + return ( +
+
+

+ Check your email +

+

+ We sent an activation link to {submittedEmail}. Click the link to activate your account. +

+
+ + Go to log in + + +
+
+
+ ); + } -const LoginForm = () => { - const { handleSubmit, handleChange, values } = useFormik({ - initialValues: { - email: "", - password: "", - }, - onSubmit: (values) => { - console.log("values", values); - // make registration post request here. - }, - }); return ( - <> -
-

- Register +
+
+

+ Create account

- -
- - -
-
- - -
- -
-
-

+ {signupError && ( +

{signupError}

+ )} + +
+ + + {touched.first_name && errors.first_name && ( +

{errors.first_name}

+ )} +
+ +
+ + + {touched.last_name && errors.last_name && ( +

{errors.last_name}

+ )} +
+ +
+ + + {touched.email && errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + {touched.password && errors.password && ( +

{errors.password}

+ )} +
+ +
+ + + {touched.re_password && errors.re_password && ( +

{errors.re_password}

+ )} +
+ + + +

Already have an account?{" "} - {" "} - Login here. + Log in

- +

); }; -export default LoginForm; +export default RegistrationForm; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dc974e85..b94cb64f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -19,6 +19,8 @@ import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; +import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; +import Activate from "../pages/Activate/Activate.tsx"; const routes = [ { @@ -28,17 +30,17 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", @@ -48,6 +50,10 @@ const routes = [ path: "register", element: , }, + { + path: "activate/:uid/:token", + element: , + }, { path: "login", element: , @@ -86,11 +92,11 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -98,7 +104,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index a6a30ff3..43c95fd7 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -233,64 +233,58 @@ export const reset_password_confirm = } }; -// export const signup = -// (first_name, last_name, email, password, re_password) => -// async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; - -// const body = JSON.stringify({ -// first_name, -// last_name, -// email, -// password, -// re_password, -// }); - -// try { -// const res = await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/`, -// body, -// config -// ); +export const signup = + (first_name: string, last_name: string, email: string, password: string, re_password: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// dispatch({ -// type: SIGNUP_SUCCESS, -// payload: res.data, -// }); -// } catch (err) { -// dispatch({ -// type: SIGNUP_FAIL, -// }); -// } -// }; + const body = JSON.stringify({ first_name, last_name, email, password, re_password }); -// export const verify = -// (uid, token) => async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; + try { + const res = await axios.post(AUTH_ENDPOINTS.USERS_CREATE, body, config); + dispatch({ + type: SIGNUP_SUCCESS, + payload: res.data, + }); + } catch (err) { + let errorMessage = "Registration failed"; + if (isAxiosError(err) && err.response) { + const messages = Object.values(err.response.data as Record).flat(); + if (messages.length > 0) errorMessage = messages.join(" "); + } + dispatch({ + type: SIGNUP_FAIL, + payload: errorMessage, + }); + throw err; + } + }; -// const body = JSON.stringify({ uid, token }); +export const verify = + (uid: string, token: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// try { -// await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/activation/`, -// body, -// config -// ); + const body = JSON.stringify({ uid, token }); -// dispatch({ -// type: ACTIVATION_SUCCESS, -// }); -// } catch (err) { -// dispatch({ -// type: ACTIVATION_FAIL, -// }); -// } -// }; + try { + await axios.post(AUTH_ENDPOINTS.USERS_ACTIVATION, body, config); + dispatch({ + type: ACTIVATION_SUCCESS, + payload: "", + }); + } catch (err) { + dispatch({ + type: ACTIVATION_FAIL, + }); + throw err; + } + }; diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..9cc5d278 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -68,12 +68,15 @@ const initialState: StateType = { export default function authReducer(state = initialState, action: ActionType): StateType { switch(action.type) { - case AUTHENTICATED_SUCCESS: + case AUTHENTICATED_SUCCESS: { + const token = localStorage.getItem('access'); + const decoded: TokenClaims = token ? jwtDecode(token) : { is_superuser: false }; return { ...state, isAuthenticated: true, - isSuperuser: true + isSuperuser: decoded.is_superuser } + } case LOGIN_SUCCESS: case GOOGLE_AUTH_SUCCESS: case FACEBOOK_AUTH_SUCCESS:{ diff --git a/server/api/permissions.py b/server/api/permissions.py new file mode 100644 index 00000000..0dbe0597 --- /dev/null +++ b/server/api/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsSuperUser(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_superuser) diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 9ee6aad7..7f453200 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -1,6 +1,6 @@ from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from drf_spectacular.utils import extend_schema from .models import AI_Settings @@ -9,7 +9,7 @@ @extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsSuperUser]) def settings_view(request): if request.method == 'GET': settings = AI_Settings.objects.all() diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 1b199a7e..4321615d 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,5 +1,6 @@ from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema, inline_serializer @@ -127,6 +128,7 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + permission_classes = [IsSuperUser] serializer_class = MedicationSerializer def post(self, request): @@ -158,6 +160,7 @@ class DeleteMedication(APIView): """ API endpoint to delete medication if medication in database. """ + permission_classes = [IsSuperUser] @extend_schema( request=inline_serializer(name='DeleteMedicationRequest', fields={ diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2f80f8f3..7e4ecae5 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,7 +1,7 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers +from api.permissions import IsSuperUser from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from drf_spectacular.utils import extend_schema, inline_serializer @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = MedRuleSerializer def get(self, request, format=None): diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 020740ad..35abe976 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -97,7 +97,7 @@ def anthropic_citations(client: anthropic.Client, user_prompt: str, content_chun @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ @@ -155,7 +155,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index eda43b76..6da092ce 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,5 +1,6 @@ from rest_framework.views import APIView -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView @@ -24,7 +25,7 @@ class UploadFileView(APIView): def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] # Public access - return [IsAuthenticated()] # Auth required for other methods + return [IsSuperUser()] # Superuser required for write methods def get(self, request, format=None): print("UploadFileView, get list") @@ -217,7 +218,7 @@ def get(self, request, guid, format=None): class EditFileMetadataView(UpdateAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = UploadFileSerializer lookup_field = 'guid' diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..070ac581 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,7 +67,7 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") TEMPLATES = [ { @@ -139,12 +139,15 @@ "default": db_config, } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" -EMAIL_PORT = 587 -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = True +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +else: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = "smtp.gmail.com" + EMAIL_PORT = 587 + EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") + EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") + EMAIL_USE_TLS = True # Password validation @@ -218,6 +221,12 @@ "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } +# Domain used by Djoser to build activation and password reset links in emails. +# Should point to the frontend, not the backend, since the frontend handles these routes. +# Override in production via environment variable. +DOMAIN = os.environ.get("FRONTEND_DOMAIN", "localhost:3000") +SITE_NAME = "Balancer" + DJOSER = { "LOGIN_FIELD": "email", "USER_CREATE_PASSWORD_RETYPE": True,