diff --git a/backend/alembic/versions/f250cb93a643_add_email_verified.py b/backend/alembic/versions/f250cb93a643_add_email_verified.py new file mode 100644 index 00000000..f39b83c9 --- /dev/null +++ b/backend/alembic/versions/f250cb93a643_add_email_verified.py @@ -0,0 +1,26 @@ +"""add_email_verified + +Revision ID: f250cb93a643 +Revises: 2e1dda44cde5 +Create Date: 2026-06-28 22:31:08.365083 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f250cb93a643' +down_revision: Union[str, None] = '2e1dda44cde5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + + +def downgrade() -> None: + op.drop_column('users', 'email_verified') diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 36470dc3..56771285 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -10,11 +10,13 @@ require_admin, ) from app.core.config import get_settings -from app.core.users import check_if_email_exists +from app.core.users import check_if_email_exists, find_user_by_id +from app.core.email_verification import generate_verification_token, verify_verification_token from app.db.session import get_db from app.models.models import User from app.schemas.user import UserResponse -from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest +from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest, MessageResponse +from app.services.email_service import send_verification_email router = APIRouter( tags=["auth"]) @@ -72,6 +74,13 @@ def _create_user( db.refresh(user) return user +async def _send_verification_email(to: str, id: int): + try: + await send_verification_email(to, generate_verification_token(id)) + + except Exception: + raise HTTPException(500, "Failed to send verification email") + @router.post("/auth/login/", response_model=UserResponse) def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): @@ -138,4 +147,35 @@ def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: """ check_if_email_exists(db, body.email) - return _create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) \ No newline at end of file + return _create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) + +@router.get("/auth/verify-email/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 400: {"description": "Invalid or expired token"}, + 404: {"description": "User not found"}, + }, +) +def verify_email(token: str, db: Session = Depends(get_db)): + user_id = verify_verification_token(token) + if user_id is None: + raise HTTPException(400, "Invalid or expired token") + + user = find_user_by_id(db, user_id) + user.email_verified = True + db.commit() + return {"detail": "User email successfully verified"} + +# todo: add rate limiting +@router.post("/auth/send-email-verification/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 400: {"description": "Email already verified"}, + 500: {"description": "Failed to send verification email"}, + }, +) +async def send_email_verification(user: User = Depends(get_current_user)): + if user.email_verified: + raise HTTPException(400, "Email already verified") + + # await _send_verification_email(user.email, user.id) + + return {"detail": "Verification email successfully sent"} \ No newline at end of file diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 51e01afc..f70c06ff 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -4,20 +4,13 @@ from app.core.auth import get_current_user, require_admin from app.core.permissions import MANAGE_TOURNAMENT, MANAGE_VOLUNTEERS, has_permission -from app.core.users import check_if_email_exists +from app.core.users import check_if_email_exists, find_user_by_id from app.db.session import get_db from app.models.models import Membership, User from app.schemas.user import UserResponse, UserUpdate, AdminUserUpdate router = APIRouter(tags=["users"]) -def _find_user_by_id(db: Session, id: int) -> User: - user = db.query(User).filter(User.id == id).first() - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - return user - - # --------------------------------------------------------------------------- # GET /users/ — admin only (global unscoped list) @@ -74,7 +67,7 @@ def admin_update_user( _: User = Depends(require_admin) ): """Admin can only update a user's role and is_active status.""" - user = _find_user_by_id(db, user_id) + user = find_user_by_id(db, user_id) for field, value in body.model_dump(exclude_unset=True).items(): setattr(user, field, value) db.commit() diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 3cdf5efb..54718acf 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -46,7 +46,7 @@ def verify_password(plain: str, hashed: str) -> bool: def create_access_token(user_id: int) -> str: settings = get_settings() expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) - payload = {"sub": str(user_id), "exp": expire} + payload = {"sub": str(user_id), "exp": expire, "aud": "session"} return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM) @@ -54,7 +54,7 @@ def decode_access_token(token: str) -> Optional[int]: """Returns user_id from a valid token, or None if invalid/expired.""" settings = get_settings() try: - payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM]) + payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM], audience="session") user_id = payload.get("sub") if user_id is None: return None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 940957fe..52f02921 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -10,16 +10,19 @@ class Settings(BaseSettings): app_host: str = "0.0.0.0" app_port: int = 8000 - database_url: str = "sqlite:///./nexus.db" + database_url: str = "postgresql://nexus:nexus@127.0.0.1:5432/nexus" google_service_account_file: str = "./credentials.json" google_service_account_json: str = "" # JSON string — used in production instead of file api_key: str = "" # For direct API access / Swagger only - # Must be set to a long random string in production — never commit the real value + # Must be set to a long random string in production jwt_secret: str = "dev-secret-change-in-production" + resend_api_key: str = "" # set in .env file for dev or env vars in prod, never commit here + frontend_url: str = "http://localhost:3000/" # remember to set to actual url in prod + @lru_cache() def get_settings() -> Settings: diff --git a/backend/app/core/email_verification.py b/backend/app/core/email_verification.py new file mode 100644 index 00000000..ca4d8ed7 --- /dev/null +++ b/backend/app/core/email_verification.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional + +from jose import JWTError, jwt + +from app.core.config import get_settings + +ALGORITHM = "HS256" +VERIFICATION_TOKEN_EXPIRE_DAYS = 1 + +def generate_verification_token(user_id: int) -> str: + settings = get_settings() + expire = datetime.now(timezone.utc) + timedelta(days=VERIFICATION_TOKEN_EXPIRE_DAYS) + payload = {"sub": str(user_id), "exp": expire, "aud": "email_verification"} + return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM) + +def verify_verification_token(token: str) -> Optional[int]: + """Returns user_id if valid token, or None if invalid/expired/wrong purpose""" + settings = get_settings() + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM], audience="email_verification") + user_id = payload.get("sub") + if user_id is None: + return None + return int(user_id) + except JWTError: + return None \ No newline at end of file diff --git a/backend/app/core/users.py b/backend/app/core/users.py index 2c0cd8b5..dc5152f8 100644 --- a/backend/app/core/users.py +++ b/backend/app/core/users.py @@ -9,4 +9,11 @@ def check_if_email_exists(db: Session, email: str): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Email already registered", - ) \ No newline at end of file + ) + + +def find_user_by_id(db: Session, id: int) -> User: + user = db.query(User).filter(User.id == id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user \ No newline at end of file diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 569e3e05..a75e5f04 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -91,6 +91,7 @@ class User(Base): # Auth fields hashed_password = Column(String(255), nullable=True) # null = cannot log in, must reset password and verify via email + email_verified = Column(Boolean, nullable=False, default=False) role = Column(String(32), nullable=False, default="user") # "admin" | "user" is_active = Column(Boolean, nullable=False, default=True) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 6130cf3d..0bc54454 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -75,4 +75,8 @@ class AdminRegisterRequest(BaseModel): # password is excluded because when user logs into their new account they will make one themselves first_name: str last_name: str - role: Literal["admin", "user"] \ No newline at end of file + role: Literal["admin", "user"] + + +class MessageResponse(BaseModel): + detail: str \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 7e3efd24..a239ae09 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -53,6 +53,7 @@ class UserResponse(BaseModel): email: EmailStr phone: Optional[str] = None + email_verified: bool role: ROLE is_active: bool diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 00000000..525a4246 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,17 @@ +import resend + +from app.core.config import get_settings + + +async def send_verification_email(to: str, token: str) -> None: + settings = get_settings() + resend.api_key = settings.resend_api_key + + params: resend.Emails.SendParams = { + "from": "NEXUS ", + "to": to, + "subject": "Verify Your Email on NEXUS", + "text": f"Please verify your email: {settings.frontend_url.rstrip('/')}/verify-email?token={token}" + } + + await resend.Emails.send_async(params) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index ec35ae12..425b1032 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,6 +28,9 @@ google-auth==2.37.0 google-auth-oauthlib==1.2.1 google-api-python-client==2.154.0 +# Resend (email services) API +resend==2.32.2 + # Testing pytest==8.3.4 pytest-asyncio==0.24.0 diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index 806402e2..25986ce2 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/Button" import { Select } from "@/components/ui/Select" import { RadioOption } from "@/components/ui/RadioOption" import { Textarea } from "@/components/ui/Textarea" +import { Modal } from "@/components/ui/Modal" @@ -60,6 +61,20 @@ function clearCookie() { document.cookie = "inSignUpFlow=; path=/; max-age=0" } +const STATE = { + ACCOUNT: 1, + STUDENT_STATUS: 2, + UNIVERSITY: 3, + EMPLOYER: 4, + COMPETED_BEFORE: 5, + COMPETITION_EXP: 6, + VOLUNTEERED_BEFORE: 7, + VOLUNTEERING_EXP: 8, + SHIRT_SIZE: 9, + DIETARY_RESTRICTIONS: 10, + DIETARY_TEXT: 11, + COMPLETE: 12, +} as const export default function SignUpPage() { // ── Sign-up step states ────────────────────────────────────────────────── @@ -76,7 +91,7 @@ export default function SignUpPage() { // 11 Dietary restriction text // 12 Complete button activated // ──────────────────────────────────────────────────────────────────────── - const [state, setState] = useState(1) + const [state, setState] = useState(STATE.ACCOUNT) const [user, setUser] = useState(null) const [loading, setLoading] = useState(false) @@ -95,6 +110,7 @@ export default function SignUpPage() { confirm: boolean }>({ length: false, upper: false, lower: false, number: false, symbol: false, confirm: false }) + const [showVerifyModal, setShowVerifyModal] = useState(false) const [profileData, setProfileData] = useState<{ student_status?: STUDENT_STATUS @@ -144,7 +160,7 @@ export default function SignUpPage() { authApi.me().then(user => { if (document.cookie.includes("inSignUpFlow")) { setUser(user) - setState(2) + setState(STATE.STUDENT_STATUS) } else { router.push('/dashboard') } @@ -182,7 +198,9 @@ export default function SignUpPage() { setCookie() - setState(2) + authApi.sendEmailVerification().then(() => { + setShowVerifyModal(true) + }).catch(() => {}).finally(() => setState(STATE.STUDENT_STATUS)) } catch (error: unknown) { if (error instanceof ApiError) { setErrors({ form1: error.message }) @@ -194,6 +212,21 @@ export default function SignUpPage() { } } + function VerifyModal() { + return ( + {}}> +
+ +

+ Your account was created successfully +

+

We sent a verification link to {user?.email ?? email}

+ +
+
+ ) + } + async function handleProfileSubmit(e: React.SyntheticEvent) { e.preventDefault() setLoading(true) @@ -242,7 +275,7 @@ export default function SignUpPage() { return ( <> - {state === 1 && ( + {state === STATE.ACCOUNT && (
-
+ {errors.form1 && ( -

+

{errors.form1}

)} @@ -378,8 +407,10 @@ export default function SignUpPage() {
)} - {state >= 2 && ( + {state >= STATE.STUDENT_STATUS && (
+ {showVerifyModal && } +

Complete Your Profile

- + setState(5)} - isActive={state === 2} + onSkip={() => setState(STATE.STUDENT_STATUS + 3)} + isActive={state === STATE.STUDENT_STATUS} > { setProfileData(d => ({...d, university: undefined, major: undefined, year_level: undefined, graduation_year: undefined})) - setState(5) + setState(STATE.UNIVERSITY + 2) }} onNext={() => { const ers: typeof errors = {} @@ -481,9 +512,9 @@ export default function SignUpPage() { else if (profileData.graduation_year < 1000 || profileData.graduation_year > 9999) ers.graduation_year = "Must be a valid year." - Object.keys(ers).length > 0 ? setErrors(er => ({...er, ...ers})) : setState(5) + Object.keys(ers).length > 0 ? setErrors(er => ({...er, ...ers})) : setState(STATE.UNIVERSITY + 2) }} - isActive={state === 3} + isActive={state === STATE.UNIVERSITY} > )} - {state >= 4 && profileData.student_status === "Non-Student" && ( + {state >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( setState(5)} + onSkip={() => setState(STATE.COMPETED_BEFORE)} onNext={() => { - !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(5) + !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(STATE.COMPETED_BEFORE) }} - isActive={state === 4} + isActive={state === STATE.EMPLOYER} > )} - {state >= 5 && ( + {state >= STATE.COMPETED_BEFORE && ( { - setState(7) + setState(STATE.COMPETED_BEFORE + 2) }} - isActive={state === 5} + isActive={state === STATE.COMPETED_BEFORE} >
- { setCompetedBefore(true) - if (state >= 7) return - setState(6) + if (state >= STATE.COMPETED_BEFORE + 2) return + setState(STATE.COMPETITION_EXP) }} label="Yes" showCircle={false} solid /> - { + onChange={() => { setCompetedBefore(false) - if (state >= 7) return - setState(7) + if (state >= STATE.COMPETED_BEFORE + 2) return + setState(STATE.COMPETED_BEFORE + 2) }} label="No" showCircle={false} @@ -557,18 +588,18 @@ export default function SignUpPage() { )} - {state >= 6 && competedBefore && ( + {state >= STATE.COMPETITION_EXP && competedBefore && ( { setCompetedBefore(null) - setState(7) + setState(STATE.VOLUNTEERED_BEFORE) }} onNext={() => { !profileData.competition_exp ? setErrors(er => ({...er, competition_exp: "Cannot be empty."})) - : setState(7) + : setState(STATE.VOLUNTEERED_BEFORE) }} - isActive={state === 6} + isActive={state === STATE.COMPETITION_EXP} >