Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f135fdd
feat(auth): separate public and admin user registration schemas; add …
ethnjs Jun 19, 2026
953c08a
feat(auth): created new admin/register/ route to separate admin and p…
ethnjs Jun 19, 2026
e1fef73
feat(phone): made normalize_phone() strict by raising an error when p…
ethnjs Jun 20, 2026
e8e8892
feat(users): add year_level and graduation_year
ethnjs Jun 20, 2026
f1d9fe8
feat(auth): add phone to public create user route
ethnjs Jun 20, 2026
28c99e0
feat(users): admin user patch route can only update role and is_activ…
ethnjs Jun 20, 2026
547b88a
feat(users): add missing fields to UserUpdate and UserResponse; add m…
ethnjs Jun 20, 2026
604d892
fix(sync): name change format_phone_us() to normalize_phone()
ethnjs Jun 20, 2026
4819f84
tests(auth): updated test with new auth routes and add phone when reg…
ethnjs Jun 20, 2026
5305401
tests(users): updated outdated tests with new user routes; removed de…
ethnjs Jun 20, 2026
b08e2b1
refactor(auth): merged AuthUser and User interfaces in api.ts; add Au…
ethnjs Jun 20, 2026
80bdc7d
feat(sign-in): skeleton of sign in page; functionally works but no st…
ethnjs Jun 23, 2026
1b337e6
feat(sign-in): add frontend email validation errors that will show in…
ethnjs Jun 23, 2026
75250cc
style(sign-in): add back button and spacing between headings and fields
ethnjs Jun 23, 2026
020d0db
feat(sign-in): add password validation for empty passwords; remove er…
ethnjs Jun 23, 2026
c5a9174
feat(sign-up): skeleton of sign up page; extract validate and format …
ethnjs Jun 24, 2026
c15c1e3
feat(sign-up): add password requirement checks
ethnjs Jun 24, 2026
505ee1c
feat(users): make first_name and last_name non-nullable; add reject n…
ethnjs Jun 26, 2026
8b79be5
feat(sign-up): add 'complete your profile' sections (student status, …
ethnjs Jun 26, 2026
3d40afc
feat(sign-up): add sections (employer, competition_exp); temp used In…
ethnjs Jun 26, 2026
a64ee18
feat(sign-up): create textarea component; add volunteering_exp section
ethnjs Jun 27, 2026
86b6b56
feat(sign-up): add sections shirt_size, dietary_restrictions
ethnjs Jun 27, 2026
e7618a5
feat(sign-up): finish sign up form; implement handleProfileSubmit() t…
ethnjs Jun 27, 2026
aa9f8f3
feat(proxy): redirect logged in users from /sign-in and /sign-up to /…
ethnjs Jun 27, 2026
b88af46
fix(sign-up): add dependency array to useEffect to prevent run every …
ethnjs Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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 ###
87 changes: 54 additions & 33 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,11 +10,13 @@
require_admin,
)
from app.core.config import get_settings
from app.core.users import check_if_email_exists
from app.db.session import get_db
from app.models.models import User
from app.schemas.auth import LoginRequest, RegisterRequest, UserResponse
from app.schemas.user import UserResponse
from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest

router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter( tags=["auth"])

COOKIE_NAME = "access_token"
COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds
Expand Down Expand Up @@ -44,8 +47,33 @@ def _clear_auth_cookie(response: Response) -> None:
domain=".ethanshih.com" if is_prod else None,
)

def _create_user(
db: Session,
email: str,
first_name: str,
last_name: str,
role: str,
phone: Optional[str] = None,
password: Optional[str] = None,
is_active: bool = True
) -> User:

@router.post("/login/", response_model=UserResponse)
user = User(
email=email.lower(),
phone=phone,
hashed_password=hash_password(password) if password else None,
first_name=first_name,
last_name=last_name,
role=role,
is_active=is_active,
)
db.add(user)
db.commit()
db.refresh(user)
return user


@router.post("/auth/login/", response_model=UserResponse)
def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)):
"""
Authenticate with email + password.
Expand Down Expand Up @@ -74,47 +102,40 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db))
return user


@router.post("/logout/", status_code=status.HTTP_200_OK)
@router.post("/auth/logout/", status_code=status.HTTP_200_OK)
def logout(response: Response):
"""Clear the auth cookie."""
_clear_auth_cookie(response)
return {"detail": "Logged out"}


@router.get("/me/", response_model=UserResponse)
@router.get("/auth/me/", response_model=UserResponse)
def me(current_user: User = Depends(get_current_user)):
"""Return the currently authenticated user."""
return current_user


@router.post("/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(
body: RegisterRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
@router.post("/auth/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)):
"""
Create a new user account.
Admin-only. All registered users get role="user".
Admin accounts are created directly in the DB or via a future
admin-promotion endpoint.
Public route to create a new user account.
All registered users get role="user".
"""
existing = db.query(User).filter(User.email == body.email.lower()).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
check_if_email_exists(db, body.email)

user = User(
email=body.email.lower(),
hashed_password=hash_password(body.password),
first_name=body.first_name,
last_name=body.last_name,
role="user",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
user = _create_user(db, body.email, body.first_name, body.last_name, "user", body.phone, body.password)

token = create_access_token(user.id)
_set_auth_cookie(response, token)

return user

@router.post("/admin/auth/register/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: User = Depends(require_admin)):
"""
Admin only. Can create normal users and admin users. Password is excluded to allow the newly created user
to set their own.
"""
check_if_email_exists(db, body.email)

return _create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False)
94 changes: 57 additions & 37 deletions backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@

from app.core.auth import get_current_user, require_admin
from app.core.permissions import MANAGE_TOURNAMENT, MANAGE_VOLUNTEERS, has_permission
from app.core.users import check_if_email_exists
from app.db.session import get_db
from app.models.models import Membership, User
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.schemas.user import UserResponse, UserUpdate, AdminUserUpdate

router = APIRouter(tags=["users"])

def _find_user_by_id(db: Session, id: int) -> User:
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user



# ---------------------------------------------------------------------------
# GET /users/ — admin only (global unscoped list)
# ---------------------------------------------------------------------------
@router.get("/users/", response_model=list[UserRead])
@router.get("/admin/users/", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
Expand All @@ -23,27 +31,10 @@ def list_users(
return db.query(User).order_by(User.last_name, User.first_name).all()


@router.post("/users/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""Create a bare user record. Admin only."""
existing = db.query(User).filter(User.email == payload.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email '{payload.email}' already exists",
)
user = User(**payload.model_dump())
db.add(user)
db.commit()
db.refresh(user)
return user


@router.get("/users/{user_id}/", response_model=UserRead)
# ---------------------------------------------------------------------------
# GET /users/{user_id}/ — admin only
# ---------------------------------------------------------------------------
@router.get("/admin/users/{user_id}/", response_model=UserResponse)
def get_user(
user_id: int,
db: Session = Depends(get_db),
Expand All @@ -56,7 +47,10 @@ def get_user(
return user


@router.get("/users/by-email/{email}/", response_model=UserRead)
# ---------------------------------------------------------------------------
# GET /users/by-email/{email}/ — admin only
# ---------------------------------------------------------------------------
@router.get("/admin/users/by-email/{email}/", response_model=UserResponse)
def get_user_by_email(
email: str,
db: Session = Depends(get_db),
Expand All @@ -69,26 +63,29 @@ def get_user_by_email(
return user


@router.patch("/users/{user_id}/", response_model=UserRead)
def update_user(
# ---------------------------------------------------------------------------
# PATCH /admin/users/{user_id}/ — admin only
# ---------------------------------------------------------------------------
@router.patch("/admin/users/{user_id}/", response_model=UserResponse)
def admin_update_user(
user_id: int,
payload: UserUpdate,
body: AdminUserUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_admin)
):
"""Update any user. Admin only."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
for field, value in payload.model_dump(exclude_none=True).items():
"""Admin can only update a user's role and is_active status."""
user = _find_user_by_id(db, user_id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user


@router.delete("/users/{user_id}/", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
# ---------------------------------------------------------------------------
# DELETE /admin/users/{user_id}/ — admin only
# ---------------------------------------------------------------------------
@router.delete("/admin/users/{user_id}/", status_code=status.HTTP_204_NO_CONTENT)
def admin_delete_user(
user_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
Expand All @@ -105,7 +102,7 @@ def delete_user(
# GET /tournaments/{tournament_id}/users/{user_id}
# Requires manage_volunteers or manage_tournament for that tournament.
# ---------------------------------------------------------------------------
@router.get("/tournaments/{tournament_id}/users/{user_id}/", response_model=UserRead)
@router.get("/tournaments/{tournament_id}/users/{user_id}/", response_model=UserResponse)
def get_tournament_user(
tournament_id: int,
user_id: int,
Expand Down Expand Up @@ -137,4 +134,27 @@ def get_tournament_user(
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user


# ---------------------------------------------------------------------------
# PATCH /users/me/ — authenticated user updates their own profile
# ---------------------------------------------------------------------------
@router.patch("/users/me/", response_model=UserResponse)
def update_user_me(
body: UserUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)
):
"""
Update the current user's own profile.
Omitted fields are left unchanged. Explicit null clears a field.
Email uniqueness is checked before applying changes.
"""
for field, value in body.model_dump(exclude_unset=True).items():
if field == "email":
check_if_email_exists(db, body.email)
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
17 changes: 4 additions & 13 deletions backend/app/core/phone.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")

Loading
Loading