From f135fdd96094b123e5008190fe55ea461b334775 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 15:03:39 -0700 Subject: [PATCH 01/25] feat(auth): separate public and admin user registration schemas; add password validation --- backend/app/models/models.py | 5 ++- backend/app/schemas/auth.py | 64 ++++++++++++++++++++++++++++++++---- backend/app/schemas/user.py | 25 +++++++------- 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 80e6d1d6..7a2ddf39 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -94,13 +94,12 @@ class User(Base): major = Column(String(255), nullable=True) employer = Column(String(255), 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" + student_status = Column(String(255), nullable=True) # e.g. "1st Year", "Graduate", "Alumni" 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 + 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) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index a87bce4a..7e69abe2 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,5 @@ -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 @@ -7,15 +7,65 @@ 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 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. + first_name: str + last_name: str + # role is intentionally excluded — all publicly registered users are "user". + + @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 + # 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"] class UserResponse(BaseModel): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 59b294d1..9cf261a0 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Optional from datetime import datetime from pydantic import BaseModel, field_validator from app.core.phone import format_phone_us @@ -34,18 +35,18 @@ class UserCreate(UserBase): 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 + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + shirt_size: Optional[str] = None + dietary_restriction: Optional[str] = None + university: Optional[str] = None + major: Optional[str] = None + employer: Optional[str] = None + university: Optional[str] = None + major: Optional[str] = None + employer: Optional[str] = None @field_validator("phone", mode="before") @classmethod From 953c08a7d0662db954fc8952fede72038bf8ed28 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 15:45:09 -0700 Subject: [PATCH 02/25] feat(auth): created new admin/register/ route to separate admin and public user registration; extracted user creation into helper function --- backend/app/api/routes/auth.py | 81 ++++++++----- backend/tests/api/test_auth.py | 206 ++++++++++++++++++++++++++++----- 2 files changed, 229 insertions(+), 58 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index c93c453d..43c1cf7d 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, @@ -11,7 +12,7 @@ from app.core.config import get_settings from app.db.session import get_db from app.models.models import User -from app.schemas.auth import LoginRequest, RegisterRequest, UserResponse +from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest, UserResponse router = APIRouter(prefix="/auth", tags=["auth"]) @@ -44,6 +45,37 @@ def _clear_auth_cookie(response: Response) -> None: domain=".ethanshih.com" if is_prod else None, ) +def _check_if_user_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", + ) + +def _create_user( + db: Session, + email: str, + first_name: str, + last_name: str, + role: str, + password: Optional[str] = None, + is_active: bool = True + ) -> User: + + user = User( + email=email.lower(), + 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("/login/", response_model=UserResponse) def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): @@ -88,33 +120,26 @@ def me(current_user: User = Depends(get_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), -): +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_user_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.password) + + token = create_access_token(user.id) + _set_auth_cookie(response, token) + + return user + +@router.post("/admin/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_user_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/tests/api/test_auth.py b/backend/tests/api/test_auth.py index 34998df9..1c1606cc 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -104,56 +104,202 @@ 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 + + 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", + "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", + "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", + "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", + "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", + "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", + "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", "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", "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", "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", "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", "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", "password": "Secure@1\x01", + "first_name": "New", "last_name": "User", + }).status_code == 422 + - def test_registered_user_can_login(self, client, admin_user): +# --------------------------------------------------------------------------- +# POST /auth/admin/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("/auth/admin/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("/auth/admin/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("/auth/admin/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("/auth/admin/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("/auth/admin/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("/auth/admin/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("/auth/admin/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("/auth/admin/register/", json={ + "email": "new@test.com", + "first_name": "New", + "last_name": "User", + "role": "superuser", + }).status_code == 422 \ No newline at end of file From e1fef737fe71a243d1da240bdd7b92070b3aab28 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 17:22:25 -0700 Subject: [PATCH 03/25] feat(phone): made normalize_phone() strict by raising an error when phone is not formatted correctly; added phone to RegisterRequest --- backend/app/api/routes/auth.py | 1 + backend/app/core/phone.py | 17 ++++------------- backend/app/models/models.py | 23 ++++++++++++++--------- backend/app/schemas/auth.py | 10 ++++++++++ backend/app/schemas/user.py | 10 ++++------ 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 43c1cf7d..bfe20de1 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -56,6 +56,7 @@ def _check_if_user_exists(db: Session, email: str): def _create_user( db: Session, email: str, + phone: str, first_name: str, last_name: str, role: str, 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/models/models.py b/backend/app/models/models.py index 7a2ddf39..2d242fcc 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -88,21 +88,26 @@ class User(Base): last_name = Column(String(100), nullable=True) 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) - 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) # e.g. "1st Year", "Graduate", "Alumni" - 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, 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) + 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 + + 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 7e69abe2..a7692d03 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -2,6 +2,8 @@ from typing import Optional, Literal from datetime import datetime +from app.core.phone import normalize_phone as _normalize_phone + class LoginRequest(BaseModel): email: EmailStr @@ -18,11 +20,18 @@ class LoginRequest(BaseModel): class RegisterRequest(BaseModel): email: EmailStr + phone: str password: str 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: @@ -62,6 +71,7 @@ def check_password(cls, password: str) -> str: 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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 9cf261a0..b39feb53 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -2,7 +2,7 @@ from typing import Optional from datetime import datetime from pydantic import BaseModel, field_validator -from app.core.phone import format_phone_us +from app.core.phone import normalize_phone as _normalize_phone class UserBase(BaseModel): @@ -26,7 +26,7 @@ def validate_email(cls, v: str) -> str: @field_validator("phone", mode="before") @classmethod def normalize_phone(cls, v: str | None) -> str | None: - return format_phone_us(v) + return _normalize_phone(v) class UserCreate(UserBase): @@ -43,15 +43,13 @@ class UserUpdate(BaseModel): dietary_restriction: Optional[str] = None university: Optional[str] = None major: Optional[str] = None - employer: Optional[str] = None - university: Optional[str] = None - major: Optional[str] = None + student_status: Optional[str] = None employer: Optional[str] = None @field_validator("phone", mode="before") @classmethod def normalize_phone(cls, v: str | None) -> str | None: - return format_phone_us(v) + return _normalize_phone(v) class UserRead(UserBase): From e8e8892bf1eb3e8da55936fca853e3cd32fcab54 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 18:11:02 -0700 Subject: [PATCH 04/25] feat(users): add year_level and graduation_year --- ..._add_year_level_and_graduation_year_to_.py | 32 +++++++++++++++++++ backend/app/models/models.py | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/ef714fead897_add_year_level_and_graduation_year_to_.py 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/models/models.py b/backend/app/models/models.py index 2d242fcc..f7400fb8 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -97,7 +97,9 @@ class User(Base): # if a student university = Column(String(255), nullable=True) major = Column(String(255), nullable=True) - student_status = Column(String(255), nullable=True) # e.g. "1st Year", "Graduate", "Alumni" + student_status = Column(String(255), nullable=True) # "Undergraduate", "Graduate", "Non-Student" + year_level = Column(Integer, nullable=True) + graduation_year = Column(Integer, nullable=True) # if not a student employer = Column(String(255), nullable=True) From f1d9fe84df091cce54e2bf6acfe86975b7f97926 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 18:13:59 -0700 Subject: [PATCH 05/25] feat(auth): add phone to public create user route --- backend/app/api/routes/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index bfe20de1..b881f9aa 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -66,6 +66,7 @@ def _create_user( user = User( email=email.lower(), + phone=phone, hashed_password=hash_password(password) if password else None, first_name=first_name, last_name=last_name, @@ -128,7 +129,7 @@ def register(body: RegisterRequest, response: Response, db: Session = Depends(ge """ _check_if_user_exists(db, body.email) - user = _create_user(db, body.email, body.first_name, body.last_name, "user", body.password) + user = _create_user(db, body.email, body.phone, body.first_name, body.last_name, "user", body.password) token = create_access_token(user.id) _set_auth_cookie(response, token) From 28c99e0a2bd0a5f4f4a449972cefdf70d5843730 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 18:55:30 -0700 Subject: [PATCH 06/25] feat(users): admin user patch route can only update role and is_active; add current user patch route --- backend/app/api/routes/auth.py | 25 ++++----- backend/app/api/routes/users.py | 92 ++++++++++++++++++++------------- backend/app/core/users.py | 12 +++++ backend/app/schemas/user.py | 7 ++- 4 files changed, 82 insertions(+), 54 deletions(-) create mode 100644 backend/app/core/users.py diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index b881f9aa..c34a1475 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -10,11 +10,12 @@ 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, AdminRegisterRequest, UserResponse -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 @@ -45,14 +46,6 @@ def _clear_auth_cookie(response: Response) -> None: domain=".ethanshih.com" if is_prod else None, ) -def _check_if_user_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", - ) - def _create_user( db: Session, email: str, @@ -79,7 +72,7 @@ def _create_user( return user -@router.post("/login/", response_model=UserResponse) +@router.post("/auth/login/", response_model=UserResponse) def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): """ Authenticate with email + password. @@ -108,26 +101,26 @@ 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) +@router.post("/auth/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)): """ Public route to create a new user account. All registered users get role="user". """ - _check_if_user_exists(db, body.email) + check_if_email_exists(db, body.email) user = _create_user(db, body.email, body.phone, body.first_name, body.last_name, "user", body.password) @@ -136,12 +129,12 @@ def register(body: RegisterRequest, response: Response, db: Session = Depends(ge return user -@router.post("/admin/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +@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_user_exists(db, body.email) + 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..0764fe35 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 UserRead, 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[UserRead]) 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=UserRead) 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=UserRead) 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=UserRead) +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), @@ -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=UserRead) +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/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/schemas/user.py b/backend/app/schemas/user.py index b39feb53..ac59b48b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Literal from datetime import datetime from pydantic import BaseModel, field_validator from app.core.phone import normalize_phone as _normalize_phone @@ -34,7 +34,7 @@ class UserCreate(UserBase): class UserUpdate(BaseModel): - """Partial update — all fields optional. TD manual override.""" + """Partial update — all fields optional.""" first_name: Optional[str] = None last_name: Optional[str] = None email: Optional[str] = None @@ -51,6 +51,9 @@ class UserUpdate(BaseModel): def normalize_phone(cls, v: str | None) -> str | None: return _normalize_phone(v) +class AdminUserUpdate(BaseModel): + role: Optional[Literal["user", "admin"]] = None + is_active: Optional[bool] = None class UserRead(UserBase): id: int From 547b88a69b96767d75350a97d63a019434001d6d Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 21:15:51 -0700 Subject: [PATCH 07/25] feat(users): add missing fields to UserUpdate and UserResponse; add missing profile fields property to UserResponse to indicate incomplete profile --- backend/app/api/routes/auth.py | 7 +-- backend/app/api/routes/users.py | 14 ++--- backend/app/schemas/auth.py | 14 +---- backend/app/schemas/user.py | 96 ++++++++++++++++++++++----------- 4 files changed, 76 insertions(+), 55 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index c34a1475..36470dc3 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -13,7 +13,8 @@ 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, AdminRegisterRequest, UserResponse +from app.schemas.user import UserResponse +from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest router = APIRouter( tags=["auth"]) @@ -49,10 +50,10 @@ def _clear_auth_cookie(response: Response) -> None: def _create_user( db: Session, email: str, - phone: str, first_name: str, last_name: str, role: str, + phone: Optional[str] = None, password: Optional[str] = None, is_active: bool = True ) -> User: @@ -122,7 +123,7 @@ def register(body: RegisterRequest, response: Response, db: Session = Depends(ge """ check_if_email_exists(db, body.email) - user = _create_user(db, body.email, body.phone, body.first_name, body.last_name, "user", body.password) + 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) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 0764fe35..51e01afc 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -7,7 +7,7 @@ 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 UserRead, UserUpdate, AdminUserUpdate +from app.schemas.user import UserResponse, UserUpdate, AdminUserUpdate router = APIRouter(tags=["users"]) @@ -22,7 +22,7 @@ def _find_user_by_id(db: Session, id: int) -> User: # --------------------------------------------------------------------------- # GET /users/ — admin only (global unscoped list) # --------------------------------------------------------------------------- -@router.get("/admin/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), @@ -34,7 +34,7 @@ def list_users( # --------------------------------------------------------------------------- # GET /users/{user_id}/ — admin only # --------------------------------------------------------------------------- -@router.get("/admin/users/{user_id}/", response_model=UserRead) +@router.get("/admin/users/{user_id}/", response_model=UserResponse) def get_user( user_id: int, db: Session = Depends(get_db), @@ -50,7 +50,7 @@ def get_user( # --------------------------------------------------------------------------- # GET /users/by-email/{email}/ — admin only # --------------------------------------------------------------------------- -@router.get("/admin/users/by-email/{email}/", response_model=UserRead) +@router.get("/admin/users/by-email/{email}/", response_model=UserResponse) def get_user_by_email( email: str, db: Session = Depends(get_db), @@ -66,7 +66,7 @@ def get_user_by_email( # --------------------------------------------------------------------------- # PATCH /admin/users/{user_id}/ — admin only # --------------------------------------------------------------------------- -@router.patch("/admin/users/{user_id}/", response_model=UserRead) +@router.patch("/admin/users/{user_id}/", response_model=UserResponse) def admin_update_user( user_id: int, body: AdminUserUpdate, @@ -102,7 +102,7 @@ def admin_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, @@ -140,7 +140,7 @@ def get_tournament_user( # --------------------------------------------------------------------------- # PATCH /users/me/ — authenticated user updates their own profile # --------------------------------------------------------------------------- -@router.patch("/users/me/", response_model=UserRead) +@router.patch("/users/me/", response_model=UserResponse) def update_user_me( body: UserUpdate, db: Session = Depends(get_db), diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index a7692d03..6130cf3d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -75,16 +75,4 @@ 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"] - - -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 + 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 ac59b48b..4ebafad0 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,36 +1,12 @@ from __future__ import annotations from typing import Optional, Literal from datetime import datetime -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, 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() - - @field_validator("phone", mode="before") - @classmethod - def normalize_phone(cls, v: str | None) -> str | None: - return _normalize_phone(v) - - -class UserCreate(UserBase): - pass +ROLE = Literal["admin", "user"] +STUDENT_STATUS = Literal["Undergraduate", "Graduate", "Non-Student"] class UserUpdate(BaseModel): @@ -39,25 +15,81 @@ class UserUpdate(BaseModel): last_name: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None - shirt_size: Optional[str] = None - dietary_restriction: Optional[str] = None + + student_status: Optional[STUDENT_STATUS] = None university: Optional[str] = None major: Optional[str] = None - student_status: 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 + + @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() @field_validator("phone", mode="before") @classmethod - def normalize_phone(cls, v: str | None) -> str | None: + 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: str + 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" and 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 From 604d892ca9479ea385975154ed2575092fce0028 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 21:18:24 -0700 Subject: [PATCH 08/25] fix(sync): name change format_phone_us() to normalize_phone() --- backend/app/services/sync_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": From 4819f841e57c9553f336d6596f0c4e016b5be18f Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 21:21:10 -0700 Subject: [PATCH 09/25] tests(auth): updated test with new auth routes and add phone when registering --- backend/tests/api/test_auth.py | 37 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index 1c1606cc..0a0bcb1a 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -108,12 +108,14 @@ def test_me_admin_role(self, client, admin_user): # --------------------------------------------------------------------------- VALID_PASSWORD = "Secure@123" # satisfies all validator rules +VALID_PHONE = "9495551234" class TestRegister: def test_register_success(self, client): res = client.post("/auth/register/", json={ "email": "new@test.com", + "phone": VALID_PHONE, "password": VALID_PASSWORD, "first_name": "New", "last_name": "User", @@ -129,6 +131,7 @@ def test_register_sets_cookie(self, client): # Registration should log the user in immediately res = client.post("/auth/register/", json={ "email": "new@test.com", + "phone": VALID_PHONE, "password": VALID_PASSWORD, "first_name": "New", "last_name": "User", @@ -140,6 +143,7 @@ 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", @@ -151,6 +155,7 @@ def test_register_auto_login(self, client): def test_register_email_stored_lowercase(self, client): res = client.post("/auth/register/", json={ "email": "NEW@TEST.COM", + "phone": VALID_PHONE, "password": VALID_PASSWORD, "first_name": "New", "last_name": "User", @@ -161,6 +166,7 @@ def test_register_email_stored_lowercase(self, client): def test_register_duplicate_email_rejected(self, client, td_user): assert client.post("/auth/register/", json={ "email": "td@test.com", + "phone": VALID_PHONE, "password": VALID_PASSWORD, "first_name": "New", "last_name": "User", @@ -170,6 +176,7 @@ 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", @@ -179,50 +186,50 @@ def test_register_can_login_with_new_credentials(self, client): def test_register_password_too_short(self, client): assert client.post("/auth/register/", json={ - "email": "new@test.com", "password": "Ab@1", + "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", "password": "secure@123", + "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", "password": "SECURE@123", + "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", "password": "Secure@abc", + "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", "password": "Secure1234", + "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", "password": "Secure@1\x01", + "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure@1\x01", "first_name": "New", "last_name": "User", }).status_code == 422 # --------------------------------------------------------------------------- -# POST /auth/admin/register/ (admin-only account creation) +# 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") - res = client.post("/auth/admin/register/", json={ + res = client.post("/admin/auth/register/", json={ "email": "newuser@test.com", "first_name": "New", "last_name": "User", @@ -235,7 +242,7 @@ def test_admin_can_create_user(self, client, admin_user): def test_admin_can_create_admin(self, client, admin_user): login(client, "admin@test.com", "adminpass") - res = client.post("/auth/admin/register/", json={ + res = client.post("/admin/auth/register/", json={ "email": "newadmin@test.com", "first_name": "New", "last_name": "Admin", @@ -247,7 +254,7 @@ def test_admin_can_create_admin(self, client, admin_user): 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("/auth/admin/register/", json={ + res = client.post("/admin/auth/register/", json={ "email": "newuser@test.com", "first_name": "New", "last_name": "User", @@ -259,7 +266,7 @@ def test_admin_created_user_is_inactive(self, client, admin_user): 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("/auth/admin/register/", json={ + client.post("/admin/auth/register/", json={ "email": "newuser@test.com", "first_name": "New", "last_name": "User", @@ -270,7 +277,7 @@ def test_admin_created_user_cannot_login(self, client, admin_user): def test_non_admin_cannot_admin_register(self, client, td_user): login(client, "td@test.com", "tdpass") - assert client.post("/auth/admin/register/", json={ + assert client.post("/admin/auth/register/", json={ "email": "new@test.com", "first_name": "New", "last_name": "User", @@ -278,7 +285,7 @@ def test_non_admin_cannot_admin_register(self, client, td_user): }).status_code == 403 def test_unauthenticated_cannot_admin_register(self, client): - assert client.post("/auth/admin/register/", json={ + assert client.post("/admin/auth/register/", json={ "email": "new@test.com", "first_name": "New", "last_name": "User", @@ -287,7 +294,7 @@ def test_unauthenticated_cannot_admin_register(self, client): def test_admin_register_duplicate_email_rejected(self, client, admin_user, td_user): login(client, "admin@test.com", "adminpass") - assert client.post("/auth/admin/register/", json={ + assert client.post("/admin/auth/register/", json={ "email": "td@test.com", "first_name": "TD", "last_name": "User", @@ -297,7 +304,7 @@ def test_admin_register_duplicate_email_rejected(self, client, admin_user, td_us 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("/auth/admin/register/", json={ + assert client.post("/admin/auth/register/", json={ "email": "new@test.com", "first_name": "New", "last_name": "User", From 5305401d586b37503bd380e10c61abcdc0d3efd0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 19 Jun 2026 21:57:20 -0700 Subject: [PATCH 10/25] tests(users): updated outdated tests with new user routes; removed delted routes --- backend/app/schemas/user.py | 5 +- backend/tests/api/test_users.py | 441 +++++++++++++++++++------------- 2 files changed, 270 insertions(+), 176 deletions(-) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 4ebafad0..8d7db0d5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -83,8 +83,9 @@ def missing_profile_fields(self) -> list[str]: if not self.student_status: missing.append("student_status") - elif self.student_status == "Non-Student" and not self.employer: - missing.append("employer") + 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: 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 From b08e2b16e3e673a1874650f4dc8b2abe149abda9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 20 Jun 2026 14:02:57 -0700 Subject: [PATCH 11/25] refactor(auth): merged AuthUser and User interfaces in api.ts; add AuthRegister interface with phone field; update User interface with new fields --- frontend/lib/api.ts | 107 +++++++++++++++++++++------------------ frontend/lib/useAuth.tsx | 6 +-- 2 files changed, 60 insertions(+), 53 deletions(-) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 508fa252..7171d0a5 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -53,36 +53,75 @@ export const api = { get: (path: string) => request(path), post: (path: string, body: unknown) => request(path, { method: 'POST', body }), patch: (path: string, body: unknown) => request(path, { method: 'PATCH', body }), - delete: (path: string) => request(path, { method: 'DELETE' }), + delete: (path: string) => request(path, { method: 'DELETE' }), } // ------------------------------------------------------------------------- // Auth // ------------------------------------------------------------------------- -export interface AuthUser { - id: number +type ROLE = 'admin' | 'user' +type STUDENT_STATUS = "Undergraduate" | "Graduate" | "Non-Student" + +export interface User { + id: number + email: string + first_name: string + last_name: string + phone: string | null + + role: ROLE + is_active: boolean + + student_status: STUDENT_STATUS | null + university: string | null + major: string | null + year_level: number | null + graduation_year: number | null + + employer: string | null + + competition_exp: string | null + volunteering_exp: string | null + + shirt_size: string | null + dietary_restriction: string | null + + created_at: string + updated_at: string + + missing_profile_fields: string[] +} + +export interface AuthRegister { email: string - first_name: string | null - last_name: string | null - role: 'admin' | 'user' - is_active: boolean - created_at: string + phone: string + password: string + first_name: string + last_name: string } export const authApi = { - login: (email: string, password: string) => - api.post('/auth/login/', { email, password }), - - logout: () => - api.post('/auth/logout/', {}), + login: (email: string, password: string) => api.post('/auth/login/', { email, password }), + logout: () => api.post('/auth/logout/', {}), + me: () => api.get('/auth/me/'), + register: (body: AuthRegister) => api.post('/auth/register/', body), +} - me: () => - api.get('/auth/me/'), - register: (body: { email: string; password: string; first_name?: string; last_name?: string }) => - api.post('/auth/register/', body), +// ------------------------------------------------------------------------- +// Users +// ------------------------------------------------------------------------- +export const usersApi = { + list: () => api.get('/users/'), + get: (id: number) => api.get(`/users/${id}/`), + getByEmail: (email: string) => api.get(`/users/by-email/${encodeURIComponent(email)}/`), + update: (id: number, body: Partial) => api.patch(`/users/${id}/`, body), + delete: (id: number) => api.delete(`/users/${id}/`), + getForTournament: (tournamentId: number, userId: number) => + api.get(`/tournaments/${tournamentId}/users/${userId}/`), } + // ------------------------------------------------------------------------- // Tournaments // ------------------------------------------------------------------------- @@ -133,6 +172,7 @@ export const tournamentsApi = { delete: (id: number) => api.delete(`/tournaments/${id}/`), } + // ------------------------------------------------------------------------- // Events — nested under /tournaments/{id}/events/ // ------------------------------------------------------------------------- @@ -165,39 +205,6 @@ export const eventsApi = { api.delete(`/tournaments/${tournamentId}/events/${id}/`), } -// ------------------------------------------------------------------------- -// Users -// ------------------------------------------------------------------------- -export interface User { - id: number - email: string - first_name: string | null - last_name: string | null - phone: string | null - shirt_size: string | null - dietary_restriction: string | null - university: string | null - major: string | null - employer: string | null - student_status: string | null - competition_exp: string | null - volunteering_exp: string | null - role: 'admin' | 'user' - is_active: boolean - created_at: string - updated_at: string -} - -export const usersApi = { - list: () => api.get('/users/'), - get: (id: number) => api.get(`/users/${id}/`), - getByEmail: (email: string) => api.get(`/users/by-email/${encodeURIComponent(email)}/`), - update: (id: number, body: Partial) => api.patch(`/users/${id}/`, body), - delete: (id: number) => api.delete(`/users/${id}/`), - getForTournament: (tournamentId: number, userId: number) => - api.get(`/tournaments/${tournamentId}/users/${userId}/`), -} - // ------------------------------------------------------------------------- // Memberships // ------------------------------------------------------------------------- diff --git a/frontend/lib/useAuth.tsx b/frontend/lib/useAuth.tsx index 3053a611..26148516 100644 --- a/frontend/lib/useAuth.tsx +++ b/frontend/lib/useAuth.tsx @@ -1,13 +1,13 @@ 'use client' import { useState, useEffect, createContext, useContext, ReactNode } from 'react' -import { authApi, AuthUser, ApiError } from '@/lib/api' +import { authApi, User, ApiError } from '@/lib/api' // ------------------------------------------------------------------------- // Context // ------------------------------------------------------------------------- interface AuthState { - user: AuthUser | null + user: User | null loading: boolean logout: () => Promise } @@ -22,7 +22,7 @@ const AuthContext = createContext({ // Provider — wrap the dashboard layout with this // ------------------------------------------------------------------------- export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null) + const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { From 80bdc7d70fb73b7a84c5b22148cbd4c8babde3f0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 22 Jun 2026 21:54:04 -0700 Subject: [PATCH 12/25] feat(sign-in): skeleton of sign in page; functionally works but no styling --- frontend/app/(auth)/layout.tsx | 9 +++ frontend/app/(auth)/sign-in/page.tsx | 80 +++++++++++++++++++++++++++ frontend/app/(auth)/sign-out/page.tsx | 0 frontend/components/ui/Input.tsx | 2 +- frontend/lib/api.ts | 10 +++- 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 frontend/app/(auth)/layout.tsx create mode 100644 frontend/app/(auth)/sign-in/page.tsx create mode 100644 frontend/app/(auth)/sign-out/page.tsx 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..72c79d4b --- /dev/null +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -0,0 +1,80 @@ +'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' + + +export default function SignInPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const router = useRouter() + + async function handleSubmit(e: React.SyntheticEvent) { + e.preventDefault() + setLoading(true) + setError('') + + try { + await authApi.login(email, password) + + router.push('/dashboard') + } catch (error: unknown) { + if (error instanceof ApiError) { + setError(error.message) + } else { + setError("Something went wrong :(") + } + } finally { + setLoading(false) + } + } + + return ( +
+

NEXUS

+

Sign In

+ setEmail(e.target.value)} + autoComplete="email" + fullWidth + /> + setPassword(e.target.value)} + autoComplete="current-password" + fullWidth + /> + + +
+ {error && ( +

+ {error} +

+ )} +
+
+ ) +} + diff --git a/frontend/app/(auth)/sign-out/page.tsx b/frontend/app/(auth)/sign-out/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/frontend/components/ui/Input.tsx b/frontend/components/ui/Input.tsx index c7fd4f25..d4bbf2ba 100644 --- a/frontend/components/ui/Input.tsx +++ b/frontend/components/ui/Input.tsx @@ -25,7 +25,7 @@ const FONT_MAP: Record = { } export const Input = forwardRef( - ({ label, error, helper, fullWidth, font = 'sans', className = '', id, ...props }, ref) => { + ({ label, error, helper, fullWidth, font = 'mono', className = '', id, ...props }, ref) => { const generatedId = useId() const inputId = id ?? generatedId diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7171d0a5..98a01d50 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -39,7 +39,15 @@ async function request(path: string, options: RequestOptions = {}): Promise Date: Mon, 22 Jun 2026 22:17:40 -0700 Subject: [PATCH 13/25] feat(sign-in): add frontend email validation errors that will show inline on the email field --- frontend/app/(auth)/sign-in/page.tsx | 29 ++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx index 72c79d4b..03407711 100644 --- a/frontend/app/(auth)/sign-in/page.tsx +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -11,14 +11,30 @@ export default function SignInPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) - const [error, setError] = useState('') + const [errors, setErrors] = useState<{ email?: string; form?: string }>({}) const router = useRouter() + function validateEmail(email: string): string | null { + if (!email.includes('@')) return "An email address must have an @-sign." + const atLoc = email.indexOf('@') + if (atLoc === email.length - 1) return "There must be something after the @-sign." + if (!email.includes('.', atLoc)) return "The part after the @-sign is not valid. It should have a period." + if (email.indexOf('.', atLoc) === email.length - 1) return "An email address cannot end with a period." + return null + } + async function handleSubmit(e: React.SyntheticEvent) { e.preventDefault() setLoading(true) - setError('') + setErrors({}) + + const emailError = validateEmail(email) + if (emailError) { + setErrors({ email: emailError }) + setLoading(false) + return + } try { await authApi.login(email, password) @@ -26,9 +42,9 @@ export default function SignInPage() { router.push('/dashboard') } catch (error: unknown) { if (error instanceof ApiError) { - setError(error.message) + setErrors({ form: error.message }) } else { - setError("Something went wrong :(") + setErrors({ form: "Something went wrong :(" }) } } finally { setLoading(false) @@ -46,6 +62,7 @@ export default function SignInPage() { value={email} onChange={e => setEmail(e.target.value)} autoComplete="email" + error={errors.email} fullWidth /> Sign In
- {error && ( + {errors.form && (

- {error} + {errors.form}

)}
From 75250cc6cd20d09092cdee986eee129856fd6c77 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 22 Jun 2026 22:57:39 -0700 Subject: [PATCH 14/25] style(sign-in): add back button and spacing between headings and fields --- frontend/app/(auth)/sign-in/page.tsx | 108 +++++++++++++++++---------- frontend/app/globals.css | 9 +++ 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx index 03407711..22365021 100644 --- a/frontend/app/(auth)/sign-in/page.tsx +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -5,6 +5,7 @@ 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' export default function SignInPage() { @@ -52,46 +53,75 @@ export default function SignInPage() { } return ( -
-

NEXUS

-

Sign In

- setEmail(e.target.value)} - autoComplete="email" - error={errors.email} - fullWidth - /> - setPassword(e.target.value)} - autoComplete="current-password" - fullWidth - /> - - -
- {errors.form && ( -

- {errors.form} -

- )} +
+
+ +
+ +
+

NEXUS

+
+ +
+

Sign In

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

+ {errors.form} +

+ )} +
+
+
+ ) } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ace8afd3..84d2eb85 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -260,4 +260,13 @@ input, textarea, select { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E"); pointer-events: none; border-radius: inherit; +} + +.link-subtle { + color: var(--color-text-secondary); + transition: color 150ms ease; +} + +.link-subtle:hover { + color: var(--color-text-primary) } \ No newline at end of file From 020d0dbb3ae4e225dfd81111c56f1692f303b99a Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 22 Jun 2026 23:10:56 -0700 Subject: [PATCH 15/25] feat(sign-in): add password validation for empty passwords; remove error when changing field --- frontend/app/(auth)/sign-in/page.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx index 22365021..1e24494b 100644 --- a/frontend/app/(auth)/sign-in/page.tsx +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -12,7 +12,7 @@ export default function SignInPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) - const [errors, setErrors] = useState<{ email?: string; form?: string }>({}) + const [errors, setErrors] = useState<{ email?: string; password?: string; form?: string }>({}) const router = useRouter() @@ -31,8 +31,10 @@ export default function SignInPage() { setErrors({}) const emailError = validateEmail(email) - if (emailError) { - setErrors({ email: emailError }) + const passwordError = !password ? "Password is empty." : null + + if (emailError || passwordError) { + setErrors({ email: emailError ?? undefined, password: passwordError ?? undefined }) setLoading(false) return } @@ -88,7 +90,7 @@ export default function SignInPage() { type="email" placeholder="you@email.com" value={email} - onChange={e => setEmail(e.target.value)} + onChange={e => { setEmail(e.target.value); setErrors(er => ({...er, email: undefined })) }} autoComplete="email" error={errors.email} fullWidth @@ -98,8 +100,9 @@ export default function SignInPage() { type="password" placeholder="••••••••" value={password} - onChange={e => setPassword(e.target.value)} + onChange={e => { setPassword(e.target.value); setErrors(er => ({...er, password: undefined})) }} autoComplete="current-password" + error={errors.password} fullWidth /> +
+ +
+

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 + /> + { setPhone(formatPhone(e.target.value)); setErrors(er => ({...er, phone: undefined })) }} + autoComplete="tel" + error={errors.phone} + fullWidth + /> + { setPassword(e.target.value); setErrors(er => ({...er, password: undefined})) }} + autoComplete="new-password" + error={errors.password} + fullWidth + /> + { setConfirmPassword(e.target.value); setErrors(er => ({...er, confirm_password: undefined})) }} + autoComplete="new-password" + error={errors.confirm_password} + fullWidth + /> + + +
+ {errors.form1 && ( +

+ {errors.form1} +

+ )} +
+
+ + ) +} \ No newline at end of file diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 98a01d50..de794799 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -67,8 +67,8 @@ export const api = { // ------------------------------------------------------------------------- // Auth // ------------------------------------------------------------------------- -type ROLE = 'admin' | 'user' -type STUDENT_STATUS = "Undergraduate" | "Graduate" | "Non-Student" +export type ROLE = 'admin' | 'user' +export type STUDENT_STATUS = "Undergraduate" | "Graduate" | "Non-Student" export interface User { id: number diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts new file mode 100644 index 00000000..f5c64147 --- /dev/null +++ b/frontend/lib/auth.ts @@ -0,0 +1,79 @@ + + +export function validateEmail(email: string): string | null { + if (!email) return "Cannot be empty." + if (!email.includes('@')) return "An email address must have an @-sign." + const atLoc = email.indexOf('@') + if (atLoc === email.length - 1) return "There must be something after the @-sign." + if (!email.includes('.', atLoc)) return "The part after the @-sign is not valid. It should have a period." + if (email.indexOf('.', atLoc) === email.length - 1) return "An email address cannot end with a period." + return null +} + +export function validatePhone(phone: string): string | null { + if (!phone) return "Cannot be empty." + if (phone.length < 10) return "Phone number is invalid." + return null +} + +export function formatPhone(phone: string): string { + phone = phone.replace(/\D/g, '') + if (phone.length < 3) return phone + if (phone.length < 7) return `(${phone.slice(0, 3)}) ${phone.slice(3)}` + return `(${phone.slice(0, 3)}) ${phone.slice(3, 6)}-${phone.slice(6, 10)}` +} + +export interface PasswordChecks { + length: boolean + upper: boolean + lower: boolean + number: boolean + symbol: boolean + confirm: boolean +} + +function isDigit(char: string): boolean { + return char >= '0' && char <= '9'; +} + +function isUpper(char: string): boolean { + return char >= 'A' && char <= 'Z' +} + +function isLower(char: string): boolean { + return char >= 'a' && char <= 'z' +} + +function isValidSymbol(char: string): boolean { + return (char >= '!' && char <= '/') || (char >= ':' && char <= '@') || (char >= '[' && char <= '`') || (char >= '{' && char <= '~') +} + +// confirm password can be null to allow to just run the other checks on the password by itself +export function checkPassword(password: string, confirm: string | null = null): PasswordChecks { + const checks: PasswordChecks = {length: false, upper: false, lower: false, number: false, symbol: false, confirm: false} + + if (password.length >= 8) checks["length"] = true + + for (const c of password) { + if (!checks["number"] && isDigit(c)) { checks["number"] = true; continue; } + if (!checks["upper"] && isUpper(c)) { checks["upper"] = true; continue; } + if (!checks["lower"] && isLower(c)) { checks["lower"] = true; continue; } + if (!checks["symbol"] && isValidSymbol(c)) { checks["symbol"] = true; continue; } + } + + + if (password === confirm) checks["confirm"] = true + + return checks +} + +// Validates password requirements only — confirm password is checked separately +// in the component so each error maps to its own field. +export function validatePassword(password: string): string | null { + const checks = checkPassword(password) + + const { confirm, ...rest } = checks + if (Object.values(rest).some(v => !v)) return "Some password requirements are not met. " + + return null +} \ No newline at end of file From c15c1e375b607314ab96eaf240f21f8881dbf16e Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 24 Jun 2026 02:36:48 -0700 Subject: [PATCH 17/25] feat(sign-up): add password requirement checks --- frontend/app/(auth)/sign-up/page.tsx | 464 ++++++++++++++------------- frontend/components/ui/Icons.tsx | 16 + frontend/lib/auth.ts | 2 +- 3 files changed, 266 insertions(+), 216 deletions(-) diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index cde7b733..cbf873a3 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -3,230 +3,264 @@ import { useState } from "react" import { User, STUDENT_STATUS, authApi, ApiError } from "@/lib/api" import { useRouter } from "next/navigation" -import { formatPhone, validateEmail, validatePassword, validatePhone } from "@/lib/auth" -import { IconArrowLeft } from "@/components/ui/Icons" +import { checkPassword, formatPhone, validateEmail, validatePassword, validatePhone } from "@/lib/auth" +import { IconArrowLeft, IconCheckCircle, IconCheckCircleSolid, IconXCircleSolid } from "@/components/ui/Icons" import { Input } from "@/components/ui/Input" import { Button } from "@/components/ui/Button" 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 - // ──────────────────────────────────────────────────────────────────────── - 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?: string - dietary_restriction?: string - }>({}) - - - 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() - - 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 - })) - - setState(2) - } catch (error : unknown) { - if (error instanceof ApiError) { - setErrors({ form1: error.message }) - } else { - setErrors({ form1: "Something went wrong :(" }) - } - } finally { - setLoading(false) - } + // ── 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 + // ──────────────────────────────────────────────────────────────────────── + 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?: string + dietary_restriction?: string + }>({}) + + + 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() + + 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 } - return ( -
-
- -
- -
-

NEXUS

-
- -
-

Sign Up

-
+ 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 + })) + + setState(2) + } catch (error: unknown) { + if (error instanceof ApiError) { + setErrors({ form1: error.message }) + } else { + setErrors({ form1: "Something went wrong :(" }) + } + } finally { + setLoading(false) + } + } + + return ( + <> + {state === 1 && ( +
+
+ +
-
- { 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 - /> - { setPhone(formatPhone(e.target.value)); setErrors(er => ({...er, phone: undefined })) }} - autoComplete="tel" - error={errors.phone} - fullWidth - /> - { setPassword(e.target.value); setErrors(er => ({...er, password: undefined})) }} - autoComplete="new-password" - error={errors.password} - fullWidth - /> - { setConfirmPassword(e.target.value); setErrors(er => ({...er, confirm_password: undefined})) }} - autoComplete="new-password" - error={errors.confirm_password} - fullWidth - /> - - -
- {errors.form1 && ( -

- {errors.form1} -

- )} +
+

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} +

+ )} +
+
- ) + )} + + ) } \ No newline at end of file diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 38b15554..93888a4a 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -273,4 +273,20 @@ export function IconLocation({ size = 13, ...props }: IconProps) { ); +} + +export function IconCheckCircleSolid({ size = 16, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconXCircleSolid({ size = 16, ...props }: IconProps) { + return ( + + + + ); } \ No newline at end of file diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index f5c64147..8305b6b4 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -62,7 +62,7 @@ export function checkPassword(password: string, confirm: string | null = null): } - if (password === confirm) checks["confirm"] = true + if (password && password === confirm) checks["confirm"] = true return checks } From 505ee1c6f74eda398baef0b7f0f7f523ad1bb1c1 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 25 Jun 2026 17:08:49 -0700 Subject: [PATCH 18/25] feat(users): make first_name and last_name non-nullable; add reject null validator on UserUpdate --- ...cde5_make_first_name_and_last_name_non_.py | 36 +++++++++++++++++++ backend/app/models/models.py | 4 +-- backend/app/schemas/user.py | 16 ++++----- 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 backend/alembic/versions/2e1dda44cde5_make_first_name_and_last_name_non_.py 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/app/models/models.py b/backend/app/models/models.py index f7400fb8..569e3e05 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -84,8 +84,8 @@ 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) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 8d7db0d5..7e3efd24 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Optional, Literal from datetime import datetime -from pydantic import BaseModel, field_validator, computed_field +from pydantic import BaseModel, EmailStr, field_validator, computed_field from app.core.phone import normalize_phone as _normalize_phone @@ -13,7 +13,7 @@ class UserUpdate(BaseModel): """Partial update — all fields optional.""" first_name: Optional[str] = None last_name: Optional[str] = None - email: Optional[str] = None + email: Optional[EmailStr] = None phone: Optional[str] = None student_status: Optional[STUDENT_STATUS] = None @@ -30,12 +30,12 @@ class UserUpdate(BaseModel): shirt_size: Optional[str] = None dietary_restriction: Optional[str] = None - @field_validator("email") + @field_validator("first_name", "last_name", "email", "phone") @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() + def reject_null(cls, v): + if v is None: + raise ValueError("Cannot be null") + return v @field_validator("phone", mode="before") @classmethod @@ -50,7 +50,7 @@ class UserResponse(BaseModel): id: int first_name: str last_name: str - email: str + email: EmailStr phone: Optional[str] = None role: ROLE From 8b79be579ad13c2bab6bb18b40dea98b9537e394 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 25 Jun 2026 20:29:37 -0700 Subject: [PATCH 19/25] feat(sign-up): add 'complete your profile' sections (student status, uni, major, year level, grad year); add ProfileQuestion element; add User updateMe route; add error prop to Select element --- frontend/app/(auth)/sign-up/page.tsx | 198 ++++++++++++++++++++++++++- frontend/components/ui/Select.tsx | 13 +- frontend/lib/api.ts | 26 ++-- 3 files changed, 223 insertions(+), 14 deletions(-) diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index cbf873a3..b761fe84 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -1,15 +1,58 @@ 'use client' -import { useState } from "react" +import { useEffect, useState } from "react" import { User, STUDENT_STATUS, authApi, ApiError } from "@/lib/api" import { useRouter } from "next/navigation" import { checkPassword, formatPhone, validateEmail, validatePassword, validatePhone } from "@/lib/auth" -import { IconArrowLeft, IconCheckCircle, IconCheckCircleSolid, IconXCircleSolid } from "@/components/ui/Icons" +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" +interface ProfileQuestionProps { + question: string + children: React.ReactNode + showSkip?: boolean + onSkip?: () => void + showNext?: boolean + onNext?: () => void + isActive?: boolean +} + +function ProfileQuestion({ + question, + children, + showSkip = false, + onSkip = ()=>{}, + showNext = false, + onNext = ()=>{}, + isActive = false +}: ProfileQuestionProps) { + return ( +
+

{question}

+ {children} + {isActive && (showSkip || showNext) && ( +
+ {showSkip && ()} + {showNext && ()} +
+ )} +
+ ) +} + + export default function SignUpPage() { // ── Sign-up step states ────────────────────────────────────────────────── // 1 Account creation form @@ -83,6 +126,12 @@ export default function SignUpPage() { const router = useRouter() + // for dev only + useEffect(() => { + if (process.env.NODE_ENV !== 'development') return + authApi.me().then(u => { setUser(u); setState(2) }) + }, []) + async function handleRegisterSubmit(e: React.SyntheticEvent) { e.preventDefault() setLoading(true) @@ -261,6 +310,151 @@ export default function SignUpPage() {
)} + + {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 {state}

{/* for debug only, remove later */} +
+ )} ) } \ No newline at end of file diff --git a/frontend/components/ui/Select.tsx b/frontend/components/ui/Select.tsx index 824ad5f6..9510af18 100644 --- a/frontend/components/ui/Select.tsx +++ b/frontend/components/ui/Select.tsx @@ -39,6 +39,7 @@ interface SelectProps { options: SelectItem[] label?: string placeholder?: string + error?: string disabled?: boolean fullWidth?: boolean /** @@ -84,7 +85,8 @@ export function Select({ onChange, options, label, - placeholder = 'Select…', + placeholder = 'Select . . .', + error, disabled = false, fullWidth = false, size = 'md', @@ -216,7 +218,8 @@ export function Select({ // ── Render ──────────────────────────────────────────────────────────────── - const borderColor = focused && !open + // TODO: color-border-strong is too similar to color-border — focused state is barely visible + const borderColor = error ? 'var(--color-danger)' : focused && !open ? 'var(--color-border-strong)' : 'var(--color-border)' @@ -332,6 +335,12 @@ export function Select({ })} )} + + {error && ( +

+ {error} +

+ )} ) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index de794799..b129fa79 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -70,16 +70,12 @@ export const api = { export type ROLE = 'admin' | 'user' export type STUDENT_STATUS = "Undergraduate" | "Graduate" | "Non-Student" -export interface User { - id: number +interface UserBase { email: string first_name: string last_name: string phone: string | null - role: ROLE - is_active: boolean - student_status: STUDENT_STATUS | null university: string | null major: string | null @@ -93,13 +89,22 @@ export interface User { shirt_size: string | null dietary_restriction: string | null +} + +export interface User extends UserBase { + id: number + role: ROLE + is_active: boolean + created_at: string updated_at: string missing_profile_fields: string[] } +interface UserUpdate extends UserBase {} + export interface AuthRegister { email: string phone: string @@ -120,11 +125,12 @@ export const authApi = { // Users // ------------------------------------------------------------------------- export const usersApi = { - list: () => api.get('/users/'), - get: (id: number) => api.get(`/users/${id}/`), - getByEmail: (email: string) => api.get(`/users/by-email/${encodeURIComponent(email)}/`), - update: (id: number, body: Partial) => api.patch(`/users/${id}/`, body), - delete: (id: number) => api.delete(`/users/${id}/`), + list: () => api.get('/users/'), + get: (id: number) => api.get(`/users/${id}/`), + getByEmail: (email: string) => api.get(`/users/by-email/${encodeURIComponent(email)}/`), + update: (id: number, body: Partial) => api.patch(`/users/${id}/`, body), + updateMe: (body: Partial) => api.patch('/users/me/', body), + delete: (id: number) => api.delete(`/users/${id}/`), getForTournament: (tournamentId: number, userId: number) => api.get(`/tournaments/${tournamentId}/users/${userId}/`), } From 3d40afce7a428cda14d1eccbc0cfe1c76236f9cc Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 25 Jun 2026 22:56:24 -0700 Subject: [PATCH 20/25] feat(sign-up): add sections (employer, competition_exp); temp used Input element in place of text box --- frontend/app/(auth)/sign-up/page.tsx | 97 +++++++++++++++++++++++--- frontend/components/ui/RadioOption.tsx | 41 ++++++----- frontend/components/ui/Select.tsx | 8 +-- 3 files changed, 116 insertions(+), 30 deletions(-) diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index b761fe84..12657a58 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -8,15 +8,14 @@ import { IconArrowLeft, IconCheckCircleSolid, IconXCircleSolid } from "@/compone import { Input } from "@/components/ui/Input" import { Button } from "@/components/ui/Button" import { Select } from "@/components/ui/Select" +import { RadioOption } from "@/components/ui/RadioOption" interface ProfileQuestionProps { question: string children: React.ReactNode - showSkip?: boolean onSkip?: () => void - showNext?: boolean onNext?: () => void isActive?: boolean } @@ -24,12 +23,12 @@ interface ProfileQuestionProps { function ProfileQuestion({ question, children, - showSkip = false, - onSkip = ()=>{}, - showNext = false, - onNext = ()=>{}, + onSkip = undefined, + onNext = undefined, isActive = false }: ProfileQuestionProps) { + const showSkip = !!onSkip + const showNext = !!onNext return (

{question}

@@ -333,7 +332,6 @@ export default function SignUpPage() {
setState(5)} isActive={state === 2} > { + setProfileData(d => ({...d, employer: e.target.value})) + setErrors(er => ({...er, employer: undefined})) + }} + error={errors.employer} + /> + + )} + + {state >= 5 && ( + { + setProfileData(d => ({...d, competition_exp: undefined})) + setState(7) + }} + isActive={state === 5} + >
+ { + setProfileData(d => ({...d, competition_exp: ""})) + if (state >= 7) return + setState(6) + }} + label="Yes" + showCircle={false} + solid + /> + { + setProfileData(d => ({...d, competition_exp: "No competition experience."})) + if (state >= 7) return + setState(7) + }} + label="No" + showCircle={false} + solid + /> +
+
+ )} + + {state >= 6 && profileData.competition_exp !== "No competition experience." && ( + { + setProfileData(d => ({...d, competition_exp: undefined})) + setState(7) + }} + onNext={() => { + !profileData.competition_exp ? setErrors(er => ({...er, competition_exp: "Cannot be empty."})) + : setState(7) + }} + isActive={state === 6} + > { + setProfileData(d => ({...d, competition_exp: e.target.value})) + setErrors(er => ({...er, competition_exp: undefined})) + }} + error={errors.competition_exp} + /> + + )} +