diff --git a/backend/alembic/versions/2e1dda44cde5_make_first_name_and_last_name_non_.py b/backend/alembic/versions/2e1dda44cde5_make_first_name_and_last_name_non_.py new file mode 100644 index 00000000..2ce8e083 --- /dev/null +++ b/backend/alembic/versions/2e1dda44cde5_make_first_name_and_last_name_non_.py @@ -0,0 +1,36 @@ +"""make first_name and last_name non-nullable + +Revision ID: 2e1dda44cde5 +Revises: ef714fead897 +Create Date: 2026-06-25 17:05:03.706013 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '2e1dda44cde5' +down_revision: Union[str, None] = 'ef714fead897' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column('users', 'first_name', + existing_type=sa.VARCHAR(length=100), + nullable=False) + op.alter_column('users', 'last_name', + existing_type=sa.VARCHAR(length=100), + nullable=False) + + +def downgrade() -> None: + op.alter_column('users', 'last_name', + existing_type=sa.VARCHAR(length=100), + nullable=True) + op.alter_column('users', 'first_name', + existing_type=sa.VARCHAR(length=100), + nullable=True) \ No newline at end of file diff --git a/backend/alembic/versions/ef714fead897_add_year_level_and_graduation_year_to_.py b/backend/alembic/versions/ef714fead897_add_year_level_and_graduation_year_to_.py new file mode 100644 index 00000000..99b730a3 --- /dev/null +++ b/backend/alembic/versions/ef714fead897_add_year_level_and_graduation_year_to_.py @@ -0,0 +1,32 @@ +"""add_year_level_and_graduation_year_to_users + +Revision ID: ef714fead897 +Revises: 2f2394fbbc02 +Create Date: 2026-06-19 18:08:16.299942 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'ef714fead897' +down_revision: Union[str, None] = '2f2394fbbc02' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('year_level', sa.Integer(), nullable=True)) + op.add_column('users', sa.Column('graduation_year', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'graduation_year') + op.drop_column('users', 'year_level') + # ### end Alembic commands ### diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index c93c453d..36470dc3 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +from typing import Optional from app.core.auth import ( create_access_token, @@ -9,11 +10,13 @@ require_admin, ) from app.core.config import get_settings +from app.core.users import check_if_email_exists from app.db.session import get_db from app.models.models import User -from app.schemas.auth import LoginRequest, RegisterRequest, UserResponse +from app.schemas.user import UserResponse +from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest -router = APIRouter(prefix="/auth", tags=["auth"]) +router = APIRouter( tags=["auth"]) COOKIE_NAME = "access_token" COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds @@ -44,8 +47,33 @@ def _clear_auth_cookie(response: Response) -> None: domain=".ethanshih.com" if is_prod else None, ) +def _create_user( + db: Session, + email: str, + first_name: str, + last_name: str, + role: str, + phone: Optional[str] = None, + password: Optional[str] = None, + is_active: bool = True + ) -> User: -@router.post("/login/", response_model=UserResponse) + user = User( + email=email.lower(), + phone=phone, + hashed_password=hash_password(password) if password else None, + first_name=first_name, + last_name=last_name, + role=role, + is_active=is_active, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/auth/login/", response_model=UserResponse) def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): """ Authenticate with email + password. @@ -74,47 +102,40 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) return user -@router.post("/logout/", status_code=status.HTTP_200_OK) +@router.post("/auth/logout/", status_code=status.HTTP_200_OK) def logout(response: Response): """Clear the auth cookie.""" _clear_auth_cookie(response) return {"detail": "Logged out"} -@router.get("/me/", response_model=UserResponse) +@router.get("/auth/me/", response_model=UserResponse) def me(current_user: User = Depends(get_current_user)): """Return the currently authenticated user.""" return current_user -@router.post("/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -def register( - body: RegisterRequest, - db: Session = Depends(get_db), - _: User = Depends(require_admin), -): +@router.post("/auth/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)): """ - Create a new user account. - Admin-only. All registered users get role="user". - Admin accounts are created directly in the DB or via a future - admin-promotion endpoint. + Public route to create a new user account. + All registered users get role="user". """ - existing = db.query(User).filter(User.email == body.email.lower()).first() - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Email already registered", - ) + check_if_email_exists(db, body.email) - user = User( - email=body.email.lower(), - hashed_password=hash_password(body.password), - first_name=body.first_name, - last_name=body.last_name, - role="user", - is_active=True, - ) - db.add(user) - db.commit() - db.refresh(user) - return user \ No newline at end of file + user = _create_user(db, body.email, body.first_name, body.last_name, "user", body.phone, body.password) + + token = create_access_token(user.id) + _set_auth_cookie(response, token) + + return user + +@router.post("/admin/auth/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: User = Depends(require_admin)): + """ + Admin only. Can create normal users and admin users. Password is excluded to allow the newly created user + to set their own. + """ + 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 diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index d95978e0..51e01afc 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -4,17 +4,25 @@ 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.db.session import get_db from app.models.models import Membership, User -from app.schemas.user import UserCreate, UserRead, UserUpdate +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) # --------------------------------------------------------------------------- -@router.get("/users/", response_model=list[UserRead]) +@router.get("/admin/users/", response_model=list[UserResponse]) def list_users( db: Session = Depends(get_db), _: User = Depends(require_admin), @@ -23,27 +31,10 @@ def list_users( return db.query(User).order_by(User.last_name, User.first_name).all() -@router.post("/users/", response_model=UserRead, status_code=status.HTTP_201_CREATED) -def create_user( - payload: UserCreate, - db: Session = Depends(get_db), - _: User = Depends(require_admin), -): - """Create a bare user record. Admin only.""" - existing = db.query(User).filter(User.email == payload.email).first() - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"User with email '{payload.email}' already exists", - ) - user = User(**payload.model_dump()) - db.add(user) - db.commit() - db.refresh(user) - return user - - -@router.get("/users/{user_id}/", response_model=UserRead) +# --------------------------------------------------------------------------- +# GET /users/{user_id}/ — admin only +# --------------------------------------------------------------------------- +@router.get("/admin/users/{user_id}/", response_model=UserResponse) def get_user( user_id: int, db: Session = Depends(get_db), @@ -56,7 +47,10 @@ def get_user( return user -@router.get("/users/by-email/{email}/", response_model=UserRead) +# --------------------------------------------------------------------------- +# GET /users/by-email/{email}/ — admin only +# --------------------------------------------------------------------------- +@router.get("/admin/users/by-email/{email}/", response_model=UserResponse) def get_user_by_email( email: str, db: Session = Depends(get_db), @@ -69,26 +63,29 @@ def get_user_by_email( return user -@router.patch("/users/{user_id}/", response_model=UserRead) -def update_user( +# --------------------------------------------------------------------------- +# PATCH /admin/users/{user_id}/ — admin only +# --------------------------------------------------------------------------- +@router.patch("/admin/users/{user_id}/", response_model=UserResponse) +def admin_update_user( user_id: int, - payload: UserUpdate, + body: AdminUserUpdate, db: Session = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_admin) ): - """Update any user. Admin only.""" - user = db.query(User).filter(User.id == user_id).first() - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - for field, value in payload.model_dump(exclude_none=True).items(): + """Admin can only update a user's role and is_active status.""" + 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() db.refresh(user) return user - -@router.delete("/users/{user_id}/", status_code=status.HTTP_204_NO_CONTENT) -def delete_user( +# --------------------------------------------------------------------------- +# DELETE /admin/users/{user_id}/ — admin only +# --------------------------------------------------------------------------- +@router.delete("/admin/users/{user_id}/", status_code=status.HTTP_204_NO_CONTENT) +def admin_delete_user( user_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), @@ -105,7 +102,7 @@ def delete_user( # GET /tournaments/{tournament_id}/users/{user_id} # Requires manage_volunteers or manage_tournament for that tournament. # --------------------------------------------------------------------------- -@router.get("/tournaments/{tournament_id}/users/{user_id}/", response_model=UserRead) +@router.get("/tournaments/{tournament_id}/users/{user_id}/", response_model=UserResponse) def get_tournament_user( tournament_id: int, user_id: int, @@ -137,4 +134,27 @@ def get_tournament_user( user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +# --------------------------------------------------------------------------- +# PATCH /users/me/ — authenticated user updates their own profile +# --------------------------------------------------------------------------- +@router.patch("/users/me/", response_model=UserResponse) +def update_user_me( + body: UserUpdate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + """ + Update the current user's own profile. + Omitted fields are left unchanged. Explicit null clears a field. + Email uniqueness is checked before applying changes. + """ + for field, value in body.model_dump(exclude_unset=True).items(): + if field == "email": + check_if_email_exists(db, body.email) + setattr(user, field, value) + db.commit() + db.refresh(user) return user \ No newline at end of file diff --git a/backend/app/core/phone.py b/backend/app/core/phone.py index b4de9b9d..dbd0b8d0 100644 --- a/backend/app/core/phone.py +++ b/backend/app/core/phone.py @@ -1,20 +1,11 @@ from __future__ import annotations +from typing import Optional import re -def format_phone_us(value: str | None) -> str | None: +def normalize_phone(value: Optional[str]) -> Optional[str]: """ - Normalize a phone string to '(###) ###-####' when it looks like a US number. - - Accepted inputs: - - 10 digits: '9495551234', '(949)555-1234', '949-555-1234' - - 11 digits starting with 1: '+1 949 555 1234' - - Returns: - - Formatted US number when parseable - - Trimmed original string when not parseable as US - - None for null/blank input """ if value is None: return None @@ -28,7 +19,7 @@ def format_phone_us(value: str | None) -> str | None: digits = digits[1:] if len(digits) == 10: - return f"({digits[0:3]}) {digits[3:6]}-{digits[6:10]}" + return digits - return text + raise ValueError("Invalid US phone number format") diff --git a/backend/app/core/users.py b/backend/app/core/users.py new file mode 100644 index 00000000..2c0cd8b5 --- /dev/null +++ b/backend/app/core/users.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.models import User + +def check_if_email_exists(db: Session, email: str): + existing = db.query(User).filter(User.email == email.lower()).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered", + ) \ No newline at end of file diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 80e6d1d6..569e3e05 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -84,26 +84,32 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) - first_name = Column(String(100), nullable=True) - last_name = Column(String(100), nullable=True) + first_name = Column(String(100), nullable=False) + last_name = Column(String(100), nullable=False) email = Column(String(255), unique=True, index=True, nullable=False) phone = Column(String(32), nullable=True) - shirt_size = Column(String(16), nullable=True) - dietary_restriction = Column(String(255), nullable=True) + + # Auth fields + hashed_password = Column(String(255), nullable=True) # null = cannot log in, must reset password and verify via email + role = Column(String(32), nullable=False, default="user") # "admin" | "user" + is_active = Column(Boolean, nullable=False, default=True) + + # if a student university = Column(String(255), nullable=True) major = Column(String(255), nullable=True) - employer = Column(String(255), nullable=True) + student_status = Column(String(255), nullable=True) # "Undergraduate", "Graduate", "Non-Student" + year_level = Column(Integer, nullable=True) + graduation_year = Column(Integer, nullable=True) - # Promoted from extra_data — user-level attributes that travel across tournaments - student_status = Column(String(255), nullable=True) # e.g. "1st Year", "Graduate", "Alumni" + # if not a student + employer = Column(String(255), nullable=True) + competition_exp = Column(Text, nullable=True) # free-form competition experience volunteering_exp = Column(Text, nullable=True) # free-form volunteering experience - # Auth fields - hashed_password = Column(String(255), nullable=True) # null = cannot log in - role = Column(String(32), nullable=False, default="user") # "admin" | "user" - is_active = Column(Boolean, nullable=False, default=True) - + shirt_size = Column(String(16), nullable=True) + dietary_restriction = Column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index a87bce4a..6130cf3d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,30 +1,78 @@ -from pydantic import BaseModel, EmailStr -from typing import Optional +from pydantic import BaseModel, EmailStr, field_validator +from typing import Optional, Literal from datetime import datetime +from app.core.phone import normalize_phone as _normalize_phone + class LoginRequest(BaseModel): email: EmailStr password: str +PASSWORD_ERROR_MSG: dict[str, str] = { + "length": "Password must have a length of 8 or more characters.\n", + "upper": "Password must include at least one uppercase letter.\n", + "lower": "Password must include at least one lowercase letter.\n", + "number": "Password must include at least one number.\n", + "symbol": "Password must include at least one special symbol.\n", + "valid": "Password contains an invalid character.\n" +} class RegisterRequest(BaseModel): email: EmailStr + phone: str password: str - first_name: Optional[str] = None - last_name: Optional[str] = None - # role is intentionally excluded — all registered users are "user". - # Admin accounts are created directly in the DB or via a future - # admin-promotion endpoint. - - -class UserResponse(BaseModel): - id: int - email: str - first_name: Optional[str] - last_name: Optional[str] - role: str # "admin" | "user" - is_active: bool - created_at: datetime - - model_config = {"from_attributes": True} \ No newline at end of file + first_name: str + last_name: str + # role is intentionally excluded — all publicly registered users are "user". + + @field_validator("phone") + @classmethod + def normalize_phone(cls, phone: str) -> str: + return _normalize_phone(phone) + + + @field_validator("password") + @classmethod + def check_password(cls, password: str) -> str: + # must be all true to pass + checks: dict[str, bool] = {"length": False, "upper": False, "lower": False, "number": False, "symbol": False, "valid": True} + if len(password) >= 8: + checks["length"] = True + + for c in password: + value = ord(c) + if not checks["number"] and c.isdigit(): + checks["number"] = True + continue + if not checks["upper"] and c.isupper(): + checks["upper"] = True + continue + if not checks["lower"] and c.islower(): + checks["lower"] = True + continue + if not checks["symbol"] and (33 <= value <= 47 or 58 <= value <= 64 or 91 <= value <= 96 or 123 <= value <= 126): + checks["symbol"] = True + continue + if value <= 32 or value >= 127: + checks["valid"] = False + + if any(not value for value in checks.values()): + msg = "" + for key, value in checks.items(): + if not value: + msg += PASSWORD_ERROR_MSG[key] + + raise ValueError(msg) + + return password + + + +class AdminRegisterRequest(BaseModel): + email: EmailStr + # phone excluded b/c user will set themselves when they make their own account + # 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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 59b294d1..7e3efd24 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,61 +1,96 @@ from __future__ import annotations +from typing import Optional, Literal from datetime import datetime -from pydantic import BaseModel, field_validator -from app.core.phone import format_phone_us +from pydantic import BaseModel, EmailStr, field_validator, computed_field +from app.core.phone import normalize_phone as _normalize_phone -class UserBase(BaseModel): - first_name: str - last_name: str - email: str - phone: str | None = None - shirt_size: str | None = None - dietary_restriction: str | None = None - university: str | None = None - major: str | None = None - employer: str | None = None - - @field_validator("email") - @classmethod - def validate_email(cls, v: str) -> str: - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email address") - return v.lower().strip() +ROLE = Literal["admin", "user"] +STUDENT_STATUS = Literal["Undergraduate", "Graduate", "Non-Student"] - @field_validator("phone", mode="before") - @classmethod - def normalize_phone(cls, v: str | None) -> str | None: - return format_phone_us(v) +class UserUpdate(BaseModel): + """Partial update — all fields optional.""" + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None -class UserCreate(UserBase): - pass + student_status: Optional[STUDENT_STATUS] = None + university: Optional[str] = None + major: Optional[str] = None + year_level: Optional[int] = None + graduation_year: Optional[int] = None + employer: Optional[str] = None + + competition_exp: Optional[str] = None + volunteering_exp: Optional[str] = None -class UserUpdate(BaseModel): - """Partial update — all fields optional. TD manual override.""" - first_name: str | None = None - last_name: str | None = None - email: str | None = None - phone: str | None = None - shirt_size: str | None = None - dietary_restriction: str | None = None - university: str | None = None - major: str | None = None - employer: str | None = None - university: str | None = None - major: str | None = None - employer: str | None = None + shirt_size: Optional[str] = None + dietary_restriction: Optional[str] = None + + @field_validator("first_name", "last_name", "email", "phone") + @classmethod + def reject_null(cls, v): + if v is None: + raise ValueError("Cannot be null") + return v @field_validator("phone", mode="before") @classmethod - def normalize_phone(cls, v: str | None) -> str | None: - return format_phone_us(v) + def normalize_phone(cls, v: Optional[str]) -> str | None: + return _normalize_phone(v) +class AdminUserUpdate(BaseModel): + role: Optional[Literal["user", "admin"]] = None + is_active: Optional[bool] = None -class UserRead(UserBase): +class UserResponse(BaseModel): id: int + first_name: str + last_name: str + email: EmailStr + phone: Optional[str] = None + + role: ROLE + is_active: bool + + student_status: Optional[STUDENT_STATUS] = None + university: Optional[str] = None + major: Optional[str] = None + year_level: Optional[int] = None + graduation_year: Optional[int] = None + + employer: Optional[str] = None + + competition_exp: Optional[str] = None + volunteering_exp: Optional[str] = None + + shirt_size: Optional[str] = None + dietary_restriction: Optional[str] = None + created_at: datetime updated_at: datetime model_config = {"from_attributes": True} + + @computed_field + @property + def missing_profile_fields(self) -> list[str]: + always_required = ["phone", "competition_exp", "volunteering_exp", "shirt_size", "dietary_restriction"] + missing = [f for f in always_required if not getattr(self, f)] + + if not self.student_status: + missing.append("student_status") + elif self.student_status == "Non-Student": + if not self.employer: + missing.append("employer") + else: + school_required = ["university", "major", "year_level", "graduation_year"] + for f in school_required: + if not getattr(self, f): + missing.append(f) + + + return missing diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py index ce3e563b..e327ea9d 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -17,7 +17,7 @@ from sqlalchemy.orm import Session from app.models.models import Event, Membership, SheetConfig, Tournament, User -from app.core.phone import format_phone_us +from app.core.phone import normalize_phone from app.schemas.sheet_config import ( PARSE_TIME_RANGE_ACTIONS, SyncError, @@ -402,7 +402,7 @@ def _process_cell( if parsed is None: return None if mapping.get("field") == "phone": - return format_phone_us(parsed) + return normalize_phone(parsed) return parsed if field_type == "boolean": diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index 34998df9..0a0bcb1a 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -104,56 +104,209 @@ def test_me_admin_role(self, client, admin_user): # --------------------------------------------------------------------------- -# POST /auth/register/ +# POST /auth/register/ (public self-registration) # --------------------------------------------------------------------------- +VALID_PASSWORD = "Secure@123" # satisfies all validator rules +VALID_PHONE = "9495551234" + + class TestRegister: - def test_admin_can_register_user(self, client, admin_user): - login(client, "admin@test.com", "adminpass") + def test_register_success(self, client): res = client.post("/auth/register/", json={ - "email": "newuser@test.com", - "password": "newpass123", + "email": "new@test.com", + "phone": VALID_PHONE, + "password": VALID_PASSWORD, "first_name": "New", "last_name": "User", }) assert res.status_code == 201 - assert res.json()["role"] == "user" - assert res.json()["email"] == "newuser@test.com" + data = res.json() + assert data["email"] == "new@test.com" + assert data["role"] == "user" + assert data["is_active"] == True + assert "hashed_password" not in data - def test_registered_user_role_is_always_user(self, client, admin_user): - login(client, "admin@test.com", "adminpass") + def test_register_sets_cookie(self, client): + # Registration should log the user in immediately res = client.post("/auth/register/", json={ - "email": "another@test.com", - "password": "pass", + "email": "new@test.com", + "phone": VALID_PHONE, + "password": VALID_PASSWORD, + "first_name": "New", + "last_name": "User", }) assert res.status_code == 201 - assert res.json()["role"] == "user" + assert "access_token" in res.cookies - def test_non_admin_cannot_register(self, client, td_user): - login(client, "td@test.com", "tdpass") + def test_register_auto_login(self, client): + # Cookie from registration should allow immediate access to /me/ + client.post("/auth/register/", json={ + "email": "new@test.com", + "phone": VALID_PHONE, + "password": VALID_PASSWORD, + "first_name": "New", + "last_name": "User", + }) + res = client.get("/auth/me/") + assert res.status_code == 200 + assert res.json()["email"] == "new@test.com" + + def test_register_email_stored_lowercase(self, client): res = client.post("/auth/register/", json={ - "email": "another@test.com", - "password": "pass", + "email": "NEW@TEST.COM", + "phone": VALID_PHONE, + "password": VALID_PASSWORD, + "first_name": "New", + "last_name": "User", }) - assert res.status_code == 403 + assert res.status_code == 201 + assert res.json()["email"] == "new@test.com" - def test_unauthenticated_cannot_register(self, client): + def test_register_duplicate_email_rejected(self, client, td_user): assert client.post("/auth/register/", json={ - "email": "new@test.com", "password": "pass", - }).status_code == 401 + "email": "td@test.com", + "phone": VALID_PHONE, + "password": VALID_PASSWORD, + "first_name": "New", + "last_name": "User", + }).status_code == 409 - def test_duplicate_email_rejected(self, client, admin_user, td_user): - login(client, "admin@test.com", "adminpass") + def test_register_can_login_with_new_credentials(self, client): + # Confirms the password was hashed and stored correctly + client.post("/auth/register/", json={ + "email": "new@test.com", + "phone": VALID_PHONE, + "password": VALID_PASSWORD, + "first_name": "New", + "last_name": "User", + }) + client.post("/auth/logout/") + assert login(client, "new@test.com", VALID_PASSWORD).status_code == 200 + + def test_register_password_too_short(self, client): assert client.post("/auth/register/", json={ - "email": "td@test.com", "password": "pass", - }).status_code == 409 + "email": "new@test.com", "phone": VALID_PHONE, "password": "Ab@1", + "first_name": "New", "last_name": "User", + }).status_code == 422 + + def test_register_password_missing_uppercase(self, client): + assert client.post("/auth/register/", json={ + "email": "new@test.com", "phone": VALID_PHONE, "password": "secure@123", + "first_name": "New", "last_name": "User", + }).status_code == 422 + + def test_register_password_missing_lowercase(self, client): + assert client.post("/auth/register/", json={ + "email": "new@test.com", "phone": VALID_PHONE, "password": "SECURE@123", + "first_name": "New", "last_name": "User", + }).status_code == 422 + + def test_register_password_missing_number(self, client): + assert client.post("/auth/register/", json={ + "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure@abc", + "first_name": "New", "last_name": "User", + }).status_code == 422 + + def test_register_password_missing_symbol(self, client): + assert client.post("/auth/register/", json={ + "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure1234", + "first_name": "New", "last_name": "User", + }).status_code == 422 + + def test_register_password_invalid_char(self, client): + # Control characters (ASCII < 32) should be rejected + assert client.post("/auth/register/", json={ + "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure@1\x01", + "first_name": "New", "last_name": "User", + }).status_code == 422 + - def test_registered_user_can_login(self, client, admin_user): +# --------------------------------------------------------------------------- +# POST /admin/auth/register/ (admin-only account creation) +# --------------------------------------------------------------------------- + +class TestAdminRegister: + def test_admin_can_create_user(self, client, admin_user): login(client, "admin@test.com", "adminpass") - client.post("/auth/register/", json={ - "email": "brand@new.com", "password": "securepass", + res = client.post("/admin/auth/register/", json={ + "email": "newuser@test.com", + "first_name": "New", + "last_name": "User", + "role": "user", + }) + assert res.status_code == 201 + data = res.json() + assert data["email"] == "newuser@test.com" + assert data["role"] == "user" + + def test_admin_can_create_admin(self, client, admin_user): + login(client, "admin@test.com", "adminpass") + res = client.post("/admin/auth/register/", json={ + "email": "newadmin@test.com", + "first_name": "New", + "last_name": "Admin", + "role": "admin", + }) + assert res.status_code == 201 + assert res.json()["role"] == "admin" + + def test_admin_created_user_is_inactive(self, client, admin_user): + # Accounts created by admin are inactive until the user activates via email + login(client, "admin@test.com", "adminpass") + res = client.post("/admin/auth/register/", json={ + "email": "newuser@test.com", + "first_name": "New", + "last_name": "User", + "role": "user", + }) + assert res.status_code == 201 + assert res.json()["is_active"] == False + + def test_admin_created_user_cannot_login(self, client, admin_user): + # Inactive + no password — login must be blocked + login(client, "admin@test.com", "adminpass") + client.post("/admin/auth/register/", json={ + "email": "newuser@test.com", + "first_name": "New", + "last_name": "User", + "role": "user", }) client.post("/auth/logout/") - res = login(client, "brand@new.com", "securepass") - assert res.status_code == 200 - assert res.json()["role"] == "user" \ No newline at end of file + assert login(client, "newuser@test.com", "anything").status_code == 401 + + def test_non_admin_cannot_admin_register(self, client, td_user): + login(client, "td@test.com", "tdpass") + assert client.post("/admin/auth/register/", json={ + "email": "new@test.com", + "first_name": "New", + "last_name": "User", + "role": "user", + }).status_code == 403 + + def test_unauthenticated_cannot_admin_register(self, client): + assert client.post("/admin/auth/register/", json={ + "email": "new@test.com", + "first_name": "New", + "last_name": "User", + "role": "user", + }).status_code == 401 + + def test_admin_register_duplicate_email_rejected(self, client, admin_user, td_user): + login(client, "admin@test.com", "adminpass") + assert client.post("/admin/auth/register/", json={ + "email": "td@test.com", + "first_name": "TD", + "last_name": "User", + "role": "user", + }).status_code == 409 + + def test_admin_register_invalid_role_rejected(self, client, admin_user): + # Schema-level: only "admin" | "user" are valid roles + login(client, "admin@test.com", "adminpass") + assert client.post("/admin/auth/register/", json={ + "email": "new@test.com", + "first_name": "New", + "last_name": "User", + "role": "superuser", + }).status_code == 422 \ No newline at end of file diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index b7a7b3ba..b81de82f 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -1,224 +1,317 @@ -"""Tests for /users and /tournaments/{id}/users endpoints.""" -import pytest -from fastapi.testclient import TestClient +"""Tests for /admin/users, /users/me, and /tournaments/{id}/users endpoints.""" from tests.conftest import login -from app.models.models import Membership - - -def _make_user(client, email="alice@example.com", **overrides): - payload = {"first_name": "Alice", "last_name": "Smith", "email": email} - payload.update(overrides) - return client.post("/users/", json=payload).json() +from app.core.auth import hash_password +from app.models.models import Membership, User + + +VALID_PHONE = "9495551234" + + +def _db_user(db, email="alice@example.com", **kwargs): + """Create a user directly in the DB for test setup.""" + defaults = { + "first_name": "Alice", + "last_name": "Smith", + "email": email, + "hashed_password": hash_password("Password@1"), + "role": "user", + "is_active": True, + } + defaults.update(kwargs) + user = User(**defaults) + db.add(user) + db.commit() + db.refresh(user) + return user # --------------------------------------------------------------------------- -# POST /users/ — admin only +# GET /admin/users/ — admin only # --------------------------------------------------------------------------- -def test_create_user_admin_only(client, admin_user): - login(client, "admin@test.com", "adminpass") - response = client.post("/users/", json={ - "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com", - }) - assert response.status_code == 201 - assert response.json()["email"] == "alice@example.com" - - -def test_create_user_non_admin_forbidden(client, td_user): - login(client, "td@test.com", "tdpass") - assert client.post("/users/", json={ - "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com", - }).status_code == 403 +class TestAdminListUsers: + def test_admin_can_list_users(self, client, admin_user, td_user): + login(client, "admin@test.com", "adminpass") + res = client.get("/admin/users/") + assert res.status_code == 200 + assert len(res.json()) >= 2 + def test_non_admin_forbidden(self, client, td_user): + login(client, "td@test.com", "tdpass") + assert client.get("/admin/users/").status_code == 403 -def test_create_user_duplicate_email(client, admin_user): - login(client, "admin@test.com", "adminpass") - _make_user(client) - assert client.post("/users/", json={ - "first_name": "Dup", "last_name": "User", "email": "alice@example.com", - }).status_code == 409 - - -def test_create_user_unauthenticated(client): - assert client.post("/users/", json={ - "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com", - }).status_code == 401 + def test_unauthenticated_forbidden(self, client): + assert client.get("/admin/users/").status_code == 401 # --------------------------------------------------------------------------- -# GET /users/ — admin only +# GET /admin/users/{id}/ — admin only # --------------------------------------------------------------------------- -def test_list_users_admin_only(client, admin_user): - login(client, "admin@test.com", "adminpass") - _make_user(client, "alice@example.com", last_name="Smith") - _make_user(client, "bob@example.com", last_name="Adams") - response = client.get("/users/") - assert response.status_code == 200 - assert len(response.json()) >= 2 - +class TestAdminGetUser: + def test_admin_can_get_user(self, client, admin_user, db): + alice = _db_user(db) + login(client, "admin@test.com", "adminpass") + assert client.get(f"/admin/users/{alice.id}/").status_code == 200 -def test_list_users_non_admin_forbidden(client, td_user): - login(client, "td@test.com", "tdpass") - assert client.get("/users/").status_code == 403 + def test_non_admin_forbidden(self, client, td_user): + login(client, "td@test.com", "tdpass") + assert client.get("/admin/users/1/").status_code == 403 - -def test_list_users_unauthenticated(client): - assert client.get("/users/").status_code == 401 + def test_not_found(self, client, admin_user): + login(client, "admin@test.com", "adminpass") + assert client.get("/admin/users/9999/").status_code == 404 # --------------------------------------------------------------------------- -# GET /users/{id}/ — admin only +# GET /admin/users/by-email/{email}/ — admin only # --------------------------------------------------------------------------- -def test_get_user_admin(client, admin_user): - login(client, "admin@test.com", "adminpass") - created = _make_user(client) - assert client.get(f"/users/{created['id']}/").status_code == 200 +class TestAdminGetUserByEmail: + def test_admin_can_get_by_email(self, client, admin_user, db): + _db_user(db) + login(client, "admin@test.com", "adminpass") + assert client.get("/admin/users/by-email/alice@example.com/").status_code == 200 + def test_not_found(self, client, admin_user): + login(client, "admin@test.com", "adminpass") + assert client.get("/admin/users/by-email/nobody@example.com/").status_code == 404 -def test_get_user_non_admin_forbidden(client, td_user): - login(client, "td@test.com", "tdpass") - assert client.get("/users/1/").status_code == 403 +# --------------------------------------------------------------------------- +# PATCH /admin/users/{id} — admin only, role + is_active only +# --------------------------------------------------------------------------- -def test_get_user_not_found(client, admin_user): - login(client, "admin@test.com", "adminpass") - assert client.get("/users/9999/").status_code == 404 +class TestAdminUpdateUser: + def test_admin_can_change_role(self, client, admin_user, db): + alice = _db_user(db) + login(client, "admin@test.com", "adminpass") + res = client.patch(f"/admin/users/{alice.id}/", json={"role": "admin"}) + assert res.status_code == 200 + assert res.json()["role"] == "admin" + + def test_admin_can_disable_user(self, client, admin_user, db): + alice = _db_user(db) + login(client, "admin@test.com", "adminpass") + res = client.patch(f"/admin/users/{alice.id}/", json={"is_active": False}) + assert res.status_code == 200 + assert res.json()["is_active"] == False + + def test_disabled_user_cannot_login(self, client, admin_user, db): + # Confirms disabling actually revokes access, not just flips a flag + alice = _db_user(db, email="alice@example.com") + login(client, "admin@test.com", "adminpass") + client.patch(f"/admin/users/{alice.id}/", json={"is_active": False}) + assert login(client, "alice@example.com", "Password@1").status_code == 401 + + def test_invalid_role_rejected(self, client, admin_user, db): + alice = _db_user(db) + login(client, "admin@test.com", "adminpass") + assert client.patch(f"/admin/users/{alice.id}/", json={"role": "superuser"}).status_code == 422 + + def test_non_admin_forbidden(self, client, td_user, db): + alice = _db_user(db) + login(client, "td@test.com", "tdpass") + assert client.patch(f"/admin/users/{alice.id}/", json={"role": "admin"}).status_code == 403 + + def test_not_found(self, client, admin_user): + login(client, "admin@test.com", "adminpass") + assert client.patch("/admin/users/9999/", json={"role": "user"}).status_code == 404 # --------------------------------------------------------------------------- -# GET /users/by-email/{email}/ — admin only +# DELETE /admin/users/{id}/ — admin only # --------------------------------------------------------------------------- -def test_get_user_by_email_admin(client, admin_user): - login(client, "admin@test.com", "adminpass") - _make_user(client) - assert client.get("/users/by-email/alice@example.com/").status_code == 200 - +class TestAdminDeleteUser: + def test_admin_can_delete_user(self, client, admin_user, db): + alice = _db_user(db) + login(client, "admin@test.com", "adminpass") + assert client.delete(f"/admin/users/{alice.id}/").status_code == 204 + assert client.get(f"/admin/users/{alice.id}/").status_code == 404 -def test_get_user_by_email_not_found(client, admin_user): - login(client, "admin@test.com", "adminpass") - assert client.get("/users/by-email/nobody@example.com/").status_code == 404 + def test_non_admin_forbidden(self, client, td_user): + login(client, "td@test.com", "tdpass") + assert client.delete("/admin/users/1/").status_code == 403 # --------------------------------------------------------------------------- -# PATCH /users/{id}/ — admin only +# PATCH /users/me/ — authenticated user updates own profile # --------------------------------------------------------------------------- -def test_update_user_admin(client, admin_user): - login(client, "admin@test.com", "adminpass") - created = _make_user(client) - response = client.patch(f"/users/{created['id']}/", json={"phone": "555-1234"}) - assert response.status_code == 200 - assert response.json()["phone"] == "555-1234" +class TestUpdateMe: + def test_can_update_name(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.patch("/users/me/", json={"first_name": "Updated"}) + assert res.status_code == 200 + assert res.json()["first_name"] == "Updated" + def test_unset_fields_unchanged(self, client, td_user): + # exclude_unset=True — omitted fields must not overwrite existing data + login(client, "td@test.com", "tdpass") + client.patch("/users/me/", json={"first_name": "Updated"}) + assert client.get("/auth/me/").json()["last_name"] == "User" -def test_update_user_admin_normalizes_us_phone(client, admin_user): - login(client, "admin@test.com", "adminpass") - created = _make_user(client) - response = client.patch(f"/users/{created['id']}/", json={"phone": "9495551234"}) - assert response.status_code == 200 - assert response.json()["phone"] == "(949) 555-1234" + def test_can_update_email(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.patch("/users/me/", json={"email": "newemail@test.com"}) + assert res.status_code == 200 + assert res.json()["email"] == "newemail@test.com" + def test_duplicate_email_rejected(self, client, td_user, admin_user): + login(client, "td@test.com", "tdpass") + assert client.patch("/users/me/", json={"email": "admin@test.com"}).status_code == 409 -def test_update_user_non_admin_forbidden(client, td_user): - login(client, "td@test.com", "tdpass") - assert client.patch("/users/1/", json={"phone": "555-0000"}).status_code == 403 + def test_invalid_email_rejected(self, client, td_user): + login(client, "td@test.com", "tdpass") + assert client.patch("/users/me/", json={"email": "notanemail"}).status_code == 422 + def test_phone_stored_as_digits(self, client, td_user): + # Formatted input should be normalized to raw digits + login(client, "td@test.com", "tdpass") + res = client.patch("/users/me/", json={"phone": "(949) 555-1234"}) + assert res.status_code == 200 + assert res.json()["phone"] == "9495551234" -# --------------------------------------------------------------------------- -# DELETE /users/{id}/ — admin only -# --------------------------------------------------------------------------- + def test_invalid_phone_rejected(self, client, td_user): + login(client, "td@test.com", "tdpass") + assert client.patch("/users/me/", json={"phone": "notaphone"}).status_code == 422 + + def test_unauthenticated_forbidden(self, client): + assert client.patch("/users/me/", json={"first_name": "X"}).status_code == 401 -def test_delete_user_admin(client, admin_user): - login(client, "admin@test.com", "adminpass") - created = _make_user(client) - assert client.delete(f"/users/{created['id']}/").status_code == 204 - assert client.get(f"/users/{created['id']}/").status_code == 404 +# --------------------------------------------------------------------------- +# missing_profile_fields — computed field on UserResponse +# --------------------------------------------------------------------------- -def test_delete_user_non_admin_forbidden(client, td_user): - login(client, "td@test.com", "tdpass") - assert client.delete("/users/1/").status_code == 403 +class TestMissingProfileFields: + def test_fresh_user_has_missing_fields(self, client, td_user): + # td_user has no profile data — phone and student_status should be missing + login(client, "td@test.com", "tdpass") + missing = client.get("/auth/me/").json()["missing_profile_fields"] + assert "phone" in missing + assert "student_status" in missing + + def test_non_student_complete_profile(self, client, td_user): + login(client, "td@test.com", "tdpass") + client.patch("/users/me/", json={ + "phone": VALID_PHONE, + "student_status": "Non-Student", + "employer": "Acme Corp", + "competition_exp": "10 years", + "volunteering_exp": "5 years", + "shirt_size": "M", + "dietary_restriction": "None", + }) + assert client.get("/auth/me/").json()["missing_profile_fields"] == [] + + def test_student_complete_profile(self, client, td_user): + login(client, "td@test.com", "tdpass") + client.patch("/users/me/", json={ + "phone": VALID_PHONE, + "student_status": "Undergraduate", + "university": "MIT", + "major": "CS", + "year_level": 2, + "graduation_year": 2027, + "competition_exp": "3 years", + "volunteering_exp": "2 years", + "shirt_size": "L", + "dietary_restriction": "None", + }) + assert client.get("/auth/me/").json()["missing_profile_fields"] == [] + + def test_student_missing_school_fields(self, client, td_user): + # student_status answered but university/major not filled in yet + login(client, "td@test.com", "tdpass") + client.patch("/users/me/", json={"phone": VALID_PHONE, "student_status": "Undergraduate"}) + missing = client.get("/auth/me/").json()["missing_profile_fields"] + assert "university" in missing + assert "major" in missing + + def test_non_student_does_not_require_university(self, client, td_user): + login(client, "td@test.com", "tdpass") + client.patch("/users/me/", json={ + "phone": VALID_PHONE, + "student_status": "Non-Student", + "employer": "Acme Corp", + "competition_exp": "10 years", + "volunteering_exp": "5 years", + "shirt_size": "M", + "dietary_restriction": "None", + }) + missing = client.get("/auth/me/").json()["missing_profile_fields"] + assert "university" not in missing + assert "major" not in missing # --------------------------------------------------------------------------- # GET /tournaments/{id}/users/{user_id}/ — manage_volunteers or manage_tournament # --------------------------------------------------------------------------- -def test_get_tournament_user_td_can_access( - client, admin_user, td_user, td_tournament, db -): - login(client, "admin@test.com", "adminpass") - alice = _make_user(client) - db.add(Membership( - user_id=alice["id"], - tournament_id=td_tournament.id, - positions=["event_supervisor"], - status="confirmed", - )) - db.commit() - login(client, "td@test.com", "tdpass") - response = client.get(f"/tournaments/{td_tournament.id}/users/{alice['id']}/") - assert response.status_code == 200 - assert response.json()["email"] == "alice@example.com" - - -def test_get_tournament_user_volunteer_coordinator_can_access( - client, admin_user, td_user, other_tournament, db -): - db.add(Membership( - user_id=td_user.id, - tournament_id=other_tournament.id, - positions=["volunteer_coordinator"], - status="confirmed", - )) - login(client, "admin@test.com", "adminpass") - alice = _make_user(client) - db.add(Membership( - user_id=alice["id"], - tournament_id=other_tournament.id, - positions=["event_supervisor"], - status="confirmed", - )) - db.commit() - login(client, "td@test.com", "tdpass") - assert client.get( - f"/tournaments/{other_tournament.id}/users/{alice['id']}/" - ).status_code == 200 - - -def test_get_tournament_user_event_supervisor_forbidden( - client, td_user, other_tournament, admin_user, db -): - db.add(Membership( - user_id=td_user.id, - tournament_id=other_tournament.id, - positions=["event_supervisor"], - status="confirmed", - )) - login(client, "admin@test.com", "adminpass") - alice = _make_user(client) - db.add(Membership( - user_id=alice["id"], - tournament_id=other_tournament.id, - positions=["event_supervisor"], - status="confirmed", - )) - db.commit() - login(client, "td@test.com", "tdpass") - assert client.get( - f"/tournaments/{other_tournament.id}/users/{alice['id']}/" - ).status_code == 403 - - -def test_get_tournament_user_not_member_of_tournament( - client, admin_user, td_user, td_tournament, db -): - login(client, "admin@test.com", "adminpass") - alice = _make_user(client) - login(client, "td@test.com", "tdpass") - assert client.get( - f"/tournaments/{td_tournament.id}/users/{alice['id']}/" - ).status_code == 404 +class TestTournamentUser: + def test_td_can_access_member(self, client, admin_user, td_user, td_tournament, db): + alice = _db_user(db) + db.add(Membership( + user_id=alice.id, + tournament_id=td_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/tournaments/{td_tournament.id}/users/{alice.id}/") + assert res.status_code == 200 + assert res.json()["email"] == "alice@example.com" + + def test_volunteer_coordinator_can_access( + self, client, admin_user, td_user, other_tournament, db + ): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["volunteer_coordinator"], + status="confirmed", + )) + alice = _db_user(db) + db.add(Membership( + user_id=alice.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.get( + f"/tournaments/{other_tournament.id}/users/{alice.id}/" + ).status_code == 200 + + def test_event_supervisor_forbidden( + self, client, td_user, other_tournament, admin_user, db + ): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + alice = _db_user(db) + db.add(Membership( + user_id=alice.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.get( + f"/tournaments/{other_tournament.id}/users/{alice.id}/" + ).status_code == 403 + + def test_non_member_returns_404(self, client, admin_user, td_user, td_tournament, db): + alice = _db_user(db) + login(client, "td@test.com", "tdpass") + assert client.get( + f"/tournaments/{td_tournament.id}/users/{alice.id}/" + ).status_code == 404 diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx new file mode 100644 index 00000000..7f70bb0b --- /dev/null +++ b/frontend/app/(auth)/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx new file mode 100644 index 00000000..86012eeb --- /dev/null +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { ApiError, authApi } from '@/lib/api' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { IconArrowLeft } from '@/components/ui/Icons' +import { validateEmail } from '@/lib/auth' + + +export default function SignInPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState<{ email?: string; password?: string; form?: string }>({}) + + const router = useRouter() + + async function handleSubmit(e: React.SyntheticEvent) { + e.preventDefault() + setLoading(true) + setErrors({}) + + const emailError = validateEmail(email) + const passwordError = !password ? "Cannot be empty." : null + + if (emailError || passwordError) { + setErrors({ email: emailError ?? undefined, password: passwordError ?? undefined }) + setLoading(false) + return + } + + try { + await authApi.login(email, password) + + router.push('/dashboard') + } catch (error: unknown) { + if (error instanceof ApiError) { + setErrors({ form: error.message }) + } else { + setErrors({ form: "Something went wrong :(" }) + } + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+ +
+

NEXUS

+
+ +
+

Sign In

+
+ +
+ { setEmail(e.target.value); setErrors(er => ({...er, email: undefined })) }} + autoComplete="email" + error={errors.email} + fullWidth + /> + { setPassword(e.target.value); setErrors(er => ({...er, password: undefined})) }} + autoComplete="current-password" + error={errors.password} + fullWidth + /> + + +
+ {errors.form && ( +

+ {errors.form} +

+ )} +
+
+
+ + ) +} + diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx new file mode 100644 index 00000000..806402e2 --- /dev/null +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -0,0 +1,767 @@ +'use client' + +import { useEffect, useState } from "react" +import { User, STUDENT_STATUS, authApi, ApiError, SHIRT_SIZE, usersApi } from "@/lib/api" +import { useRouter } from "next/navigation" +import { checkPassword, formatPhone, validateEmail, validatePassword, validatePhone } from "@/lib/auth" +import { IconArrowLeft, IconCheckCircleSolid, IconXCircleSolid } from "@/components/ui/Icons" +import { Input } from "@/components/ui/Input" +import { Button } from "@/components/ui/Button" +import { Select } from "@/components/ui/Select" +import { RadioOption } from "@/components/ui/RadioOption" +import { Textarea } from "@/components/ui/Textarea" + + + +interface ProfileQuestionProps { + question: string + children: React.ReactNode + onSkip?: () => void + onNext?: () => void + isActive?: boolean +} + +function ProfileQuestion({ + question, + children, + onSkip = undefined, + onNext = undefined, + isActive = false +}: ProfileQuestionProps) { + const showSkip = !!onSkip + const showNext = !!onNext + return ( +
+

{question}

+ {children} + {isActive && (showSkip || showNext) && ( +
+ {showSkip && ()} + {showNext && ()} +
+ )} +
+ ) +} + +function setCookie() { + document.cookie = "inSignUpFlow=true; path=/" +} + +function clearCookie() { + document.cookie = "inSignUpFlow=; path=/; max-age=0" +} + + +export default function SignUpPage() { + // ── Sign-up step states ────────────────────────────────────────────────── + // 1 Account creation form + // 2 Student status question + // 3 University, major, year level, graduation year (student path) + // 4 Employer (non-student path) + // 5 Competed in Science Olympiad before? (yes / no) + // 6 Competition experience text + // 7 Volunteered for Science Olympiad before? (yes / no) + // 8 Volunteering experience text + // 9 Shirt size + // 10 Dietary restrictions? (yes / no) + // 11 Dietary restriction text + // 12 Complete button activated + // ──────────────────────────────────────────────────────────────────────── + const [state, setState] = useState(1) + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(false) + + + const [name, setName] = useState<{ first: string, last: string }>({ first: '', last: '' }) + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [passwordChecks, setPasswordChecks] = useState<{ + length: boolean + upper: boolean + lower: boolean + number: boolean + symbol: boolean + confirm: boolean + }>({ length: false, upper: false, lower: false, number: false, symbol: false, confirm: false }) + + + const [profileData, setProfileData] = useState<{ + student_status?: STUDENT_STATUS + university?: string + major?: string + year_level?: number + graduation_year?: number + + employer?: string + + competition_exp?: string + volunteering_exp?: string + + shirt_size?: SHIRT_SIZE + dietary_restriction?: string + }>({}) + const [competedBefore, setCompetedBefore] = useState(null) + const [volunteeredBefore, setVolunteeredBefore] = useState (null) + const [hasDietary, setHasDietary] = useState(null) + + + const [errors, setErrors] = useState<{ + first_name?: string + last_name?: string + email?: string + phone?: string + password?: string + confirm_password?: string + form1?: string + + student_status?: string + university?: string + major?: string + year_level?: string + graduation_year?: string + employer?: string + competition_exp?: string + volunteering_exp?: string + shirt_size?: string + dietary_restriction?: string + form2?: string + }>({}) + + const router = useRouter() + + useEffect(() => { + authApi.me().then(user => { + if (document.cookie.includes("inSignUpFlow")) { + setUser(user) + setState(2) + } else { + router.push('/dashboard') + } + }).catch(() => {}) + }, []) + + async function handleRegisterSubmit(e: React.SyntheticEvent) { + e.preventDefault() + setLoading(true) + setErrors({}) + + const registerErrors = { + first_name: !name.first ? "Cannot be empty." : undefined, + last_name: !name.last ? "Cannot be empty." : undefined, + email: validateEmail(email) ?? undefined, + phone: validatePhone(phone) ?? undefined, + password: validatePassword(password) ?? undefined, + confirm_password: password !== confirmPassword ? "Passwords don't match." : undefined + } + + if (Object.values(registerErrors).some(v => v)) { + setErrors(registerErrors) + setLoading(false) + return + } + + try { + setUser(await authApi.register({ + email: email, + phone: phone, + password: password, + first_name: name.first, + last_name: name.last + })) + + setCookie() + + setState(2) + } catch (error: unknown) { + if (error instanceof ApiError) { + setErrors({ form1: error.message }) + } else { + setErrors({ form1: "Something went wrong :(" }) + } + } finally { + setLoading(false) + } + } + + async function handleProfileSubmit(e: React.SyntheticEvent) { + e.preventDefault() + setLoading(true) + setErrors({}) + + const cleaned = { ...profileData } + + if (cleaned.student_status === 'Undergraduate' || cleaned.student_status === 'Graduate') { + cleaned.employer = undefined + } else if (cleaned.student_status === 'Non-Student') { + cleaned.university = cleaned.major = cleaned.year_level = cleaned.graduation_year = undefined + } else { + cleaned.student_status = cleaned.employer = cleaned.university = cleaned.major = cleaned.year_level = cleaned.graduation_year = undefined + } + + cleaned.competition_exp = competedBefore === false ? "No competition experience." : competedBefore === null ? undefined : cleaned.competition_exp + cleaned.volunteering_exp = volunteeredBefore === false ? "No volunteer experience." : volunteeredBefore === null ? undefined : cleaned.volunteering_exp + + for (const key of Object.keys(cleaned)) { + if (cleaned[key as keyof typeof cleaned] === "") { + (cleaned as Record)[key] = undefined + } + } + + if (cleaned.graduation_year && (cleaned.graduation_year < 1000 || cleaned.graduation_year > 9999)) { + setErrors(er => ({...er, graduation_year: "Must be a valid year."})) + setLoading(false) + return + } + + try { + await usersApi.updateMe(cleaned) + + clearCookie() + router.push("/dashboard") + } catch (error: unknown) { + if (error instanceof ApiError) { + setErrors({ form2: error.message }) + } else { + setErrors({ form2: "Something went wrong :(" }) + } + } finally { + setLoading(false) + } + } + + return ( + <> + {state === 1 && ( +
+
+ +
+ +
+

NEXUS

+
+ +
+

Sign Up

+
+ +
+ { setName(n => ({ ...n, first: e.target.value })); setErrors(er => ({ ...er, first_name: undefined })) }} + autoComplete="given-name" + error={errors.first_name} + fullWidth + /> + { setName(n => ({ ...n, last: e.target.value })); setErrors(er => ({ ...er, last_name: undefined })) }} + autoComplete="family-name" + error={errors.last_name} + fullWidth + /> + { setEmail(e.target.value); setErrors(er => ({ ...er, email: undefined })) }} + autoComplete="email" + error={errors.email} + fullWidth + /> + { + const raw = e.target.value.replace(/\D/g, '').slice(0, 10) + setPhone(raw); + setErrors(er => ({ ...er, phone: undefined })); + }} + autoComplete="tel" + error={errors.phone} + fullWidth + /> + { + setPassword(e.target.value); + setPasswordChecks(checkPassword(e.target.value, confirmPassword)) + setErrors(er => ({ ...er, password: undefined })); + }} + autoComplete="new-password" + error={errors.password} + fullWidth + /> + { + setConfirmPassword(e.target.value); + setPasswordChecks(checkPassword(password, e.target.value)) + setErrors(er => ({ ...er, confirm_password: undefined })); + }} + autoComplete="new-password" + error={errors.confirm_password} + fullWidth + /> +
+ {[ + { key: 'length', label: 'At least 8 characters' }, + { key: 'upper', label: 'At least one uppercase letter' }, + { key: 'lower', label: 'At least one lowercase letter' }, + { key: 'number', label: 'At least one number' }, + { key: 'symbol', label: 'At least one special symbol' }, + { key: 'confirm', label: 'Both passwords match' } + ].map(({ key, label}) => ( +
+ {passwordChecks[key as keyof typeof passwordChecks] + ? + : + } + {label} +
+ ))} +
+ + +
+ {errors.form1 && ( +

+ {errors.form1} +

+ )} +
+
+
+ )} + + {state >= 2 && ( +
+
+

NEXUS

+
+ +
+

Complete Your Profile

+
+ +
+ setState(5)} + isActive={state === 2} + > { + setProfileData(d => ({...d, university: e.target.value})) + setErrors(er => ({...er, university: undefined})) + }} + error={errors.university} + /> + + + { + setProfileData(d => ({...d, major: e.target.value})) + setErrors(er => ({...er, major: undefined})) + }} + error={errors.major} + /> + + + { + const raw = e.target.value.replace(/\D/g, '').slice(0, 4) + setErrors(er => ({ ...er, graduation_year: raw.length > 0 && raw.length < 4 ? "Must be a valid year." : undefined })) + setProfileData(d => ({ ...d, graduation_year: raw ? Number(raw) : undefined })) + }} + + error={errors.graduation_year} + /> + + + )} + + {state >= 4 && profileData.student_status === "Non-Student" && ( + setState(5)} + onNext={() => { + !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(5) + }} + isActive={state === 4} + > { + setProfileData(d => ({...d, employer: e.target.value})) + setErrors(er => ({...er, employer: undefined})) + }} + error={errors.employer} + /> + + )} + + {state >= 5 && ( + { + setState(7) + }} + isActive={state === 5} + >
+ { + setCompetedBefore(true) + if (state >= 7) return + setState(6) + }} + label="Yes" + showCircle={false} + solid + /> + { + setCompetedBefore(false) + if (state >= 7) return + setState(7) + }} + label="No" + showCircle={false} + solid + /> +
+
+ )} + + {state >= 6 && competedBefore && ( + { + setCompetedBefore(null) + setState(7) + }} + onNext={() => { + !profileData.competition_exp ? setErrors(er => ({...er, competition_exp: "Cannot be empty."})) + : setState(7) + }} + isActive={state === 6} + >