From a9ce478ca1aad96fd21320235200664352326d19 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 06:32:08 -0700 Subject: [PATCH 01/55] feat(models): add verification_tokens table to track tokens sent out for email verification, email change, and password resets --- ...b9d385bd2_add_verification_tokens_table.py | 41 +++++++++++++++++++ backend/app/models/models.py | 27 ++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 backend/alembic/versions/013b9d385bd2_add_verification_tokens_table.py diff --git a/backend/alembic/versions/013b9d385bd2_add_verification_tokens_table.py b/backend/alembic/versions/013b9d385bd2_add_verification_tokens_table.py new file mode 100644 index 00000000..362b7895 --- /dev/null +++ b/backend/alembic/versions/013b9d385bd2_add_verification_tokens_table.py @@ -0,0 +1,41 @@ +"""add verification_tokens table + +Revision ID: 013b9d385bd2 +Revises: b92215c62c84 +Create Date: 2026-07-27 06:28:19.504543 + +""" +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 = '013b9d385bd2' +down_revision: Union[str, None] = 'b92215c62c84' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('verification_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=False), + sa.Column('purpose', sa.String(length=32), nullable=False), + sa.Column('new_email', sa.String(length=255), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_verification_tokens_id'), 'verification_tokens', ['id'], unique=False) + op.create_index(op.f('ix_verification_tokens_token_hash'), 'verification_tokens', ['token_hash'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_verification_tokens_token_hash'), table_name='verification_tokens') + op.drop_index(op.f('ix_verification_tokens_id'), table_name='verification_tokens') + op.drop_table('verification_tokens') diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 23de4f08..74954679 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -94,6 +94,33 @@ class User(Base): cascade="all, delete-orphan" ) +# --------------------------------------------------------------------------- +# Verification Token +# Backs signup email verification, email-change, and password reset. +# One raw token is emailed to the user; only its hash is stored here. +# +# purpose: "signup_verify" | "email_change" | "password_reset" +# new_email is only ever set for "email_change" rows. +# +# On create, any prior unconsumed row for the same (user_id, purpose) is +# marked used_at (stale-token guarding) — see app/core/verification_tokens.py. +# --------------------------------------------------------------------------- +class VerificationToken(Base): + __tablename__ = "verification_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + token_hash = Column(String(255), nullable=False, index=True, unique=True) + purpose = Column(String(32), nullable=False) # "signup_verify" | "email_change" | "password_reset" + new_email = Column(String(255), nullable=True) # only set for "email_change" + + expires_at = Column(DateTime(timezone=True), nullable=False) + used_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=utcnow) + + user = relationship("User") + # --------------------------------------------------------------------------- # Competition Experience # --------------------------------------------------------------------------- From cf1f8d169b3cf245c8f4a196a7ff088c7873aea6 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 07:23:07 -0700 Subject: [PATCH 02/55] feat(auth): add verification token helpers: create and consume --- backend/app/core/auth.py | 117 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 54718acf..f7cae812 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -3,13 +3,15 @@ - Password hashing via bcrypt (passlib) - JWT creation/decoding via python-jose +- Verification tokens (signup verify / email change / password reset) - FastAPI dependencies: get_current_user, require_admin Tournament-level permission checking lives in app/core/permissions.py. """ +import secrets from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Optional, Literal from fastapi import Cookie, Depends, HTTPException, status from jose import JWTError, jwt @@ -18,7 +20,7 @@ from app.core.config import get_settings from app.db.session import get_db -from app.models.models import User +from app.models.models import User, VerificationToken # --------------------------------------------------------------------------- # Password hashing @@ -63,6 +65,117 @@ def decode_access_token(token: str) -> Optional[int]: return None +# --------------------------------------------------------------------------- +# Verification tokens +# Backs signup email verification, email-change, and password reset. +# Raw token is emailed to the user; only its hash is ever persisted +# (VerificationToken.token_hash, via the same bcrypt context as passwords). +# --------------------------------------------------------------------------- + +Purpose = Literal["signup_verify", "email_change", "password_reset"] + +TOKEN_TTL: dict[Purpose, timedelta] = { + "signup_verify": timedelta(hours=24), + "email_change": timedelta(hours=24), + "password_reset": timedelta(hours=1), +} + +RATE_LIMIT_WINDOW = timedelta(seconds=60) + + +class RateLimitedError(Exception): + """Raised when a new token is requested too soon after a prior one.""" + pass + + +def create_verification_token( + db: Session, + user_id: int, + purpose: Purpose, + new_email: Optional[str] = None, +) -> str: + """ + Creates a new verification token for the given user + purpose. + + - Rate limits: raises RateLimitedError if an unconsumed, unexpired token + for this (user_id, purpose) was created within RATE_LIMIT_WINDOW. + - Stale-token guarding: invalidates (marks used_at) any other unconsumed + tokens for this (user_id, purpose) before issuing the new one, so only + the most recently issued link is ever valid. + + Returns the raw token — this is the only time it exists in plaintext. + """ + now = datetime.now(timezone.utc) + + existing = ( + db.query(VerificationToken) + .filter( + VerificationToken.user_id == user_id, + VerificationToken.purpose == purpose, + VerificationToken.used_at.is_(None), + VerificationToken.expires_at > now, + ) + .all() + ) + + for row in existing: + if row.created_at is not None and (now - row.created_at) < RATE_LIMIT_WINDOW: + raise RateLimitedError( + f"A {purpose} request was already made recently. Please wait before retrying." + ) + + # Stale-token guarding — invalidate any other pending tokens for this purpose + for row in existing: + row.used_at = now + + raw_token = secrets.token_urlsafe(32) + + token_row = VerificationToken( + user_id=user_id, + token_hash=hash_password(raw_token), + purpose=purpose, + new_email=new_email, + expires_at=now + TOKEN_TTL[purpose], + ) + db.add(token_row) + db.commit() + + return raw_token + + +def consume_verification_token( + db: Session, + raw_token: str, + expected_purpose: Purpose, +) -> Optional[VerificationToken]: + """ + Validates and consumes a raw token for the given purpose. + + Returns the VerificationToken row (with .user_id / .new_email available) + on success, or None if no matching, unexpired, unconsumed token is found. + Marks the row used_at on success — tokens are single-use. + """ + now = datetime.now(timezone.utc) + + candidates = ( + db.query(VerificationToken) + .filter( + VerificationToken.purpose == expected_purpose, + VerificationToken.used_at.is_(None), + VerificationToken.expires_at > now, + ) + .all() + ) + + for row in candidates: + if verify_password(raw_token, row.token_hash): + row.used_at = now + db.commit() + return row + + return None + + # --------------------------------------------------------------------------- # FastAPI dependencies # --------------------------------------------------------------------------- From df328dcb63efe8fc5fb452370a2fcec09223d581 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 07:30:48 -0700 Subject: [PATCH 03/55] refactor(auth): extract helpers from routes/auth to their corresponding helper files --- backend/app/api/routes/auth.py | 85 ++++----------------------- backend/app/core/auth.py | 33 +++++++++++ backend/app/core/users.py | 25 ++++++++ backend/app/services/email_service.py | 16 ++++- 4 files changed, 85 insertions(+), 74 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 53e26a5f..d33c5511 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,6 +1,5 @@ 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, @@ -8,78 +7,18 @@ verify_password, get_current_user, require_admin, + set_auth_cookie, + clear_auth_cookie, ) -from app.core.config import get_settings -from app.core.users import check_if_email_exists, find_user_by_id -from app.core.email_verification import generate_verification_token, verify_verification_token +from app.core.users import check_if_email_exists, find_user_by_id, create_user +from app.core.email_verification import verify_verification_token from app.db.session import get_db from app.models.models import User from app.schemas.user import UserSlimResponse from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest, MessageResponse -from app.services.email_service import send_verification_email - -router = APIRouter( tags=["auth"]) - -COOKIE_NAME = "access_token" -COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds - - -def _set_auth_cookie(response: Response, token: str) -> None: - settings = get_settings() - is_prod = settings.app_env == "production" - is_preview = settings.app_env == "preview" - response.set_cookie( - key=COOKIE_NAME, - value=token, - httponly=True, - secure=is_prod or is_preview, - samesite="none" if (is_prod or is_preview) else "lax", - max_age=COOKIE_MAX_AGE, - path="/", - domain=".ethanshih.com" if is_prod else None, - ) - - -def _clear_auth_cookie(response: Response) -> None: - settings = get_settings() - is_prod = settings.app_env == "production" - response.delete_cookie( - key=COOKIE_NAME, - path="/", - 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: - - 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 - -async def _send_verification_email(to: str, id: int): - try: - await send_verification_email(to, generate_verification_token(id)) +from app.services.email_service import send_signup_verification_email - except Exception: - raise HTTPException(500, "Failed to send verification email") +router = APIRouter(tags=["auth"]) @router.post("/auth/login/", response_model=UserSlimResponse) @@ -107,7 +46,7 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) ) token = create_access_token(user.id) - _set_auth_cookie(response, token) + set_auth_cookie(response, token) return user @@ -115,7 +54,7 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) @router.post("/auth/logout/", status_code=status.HTTP_200_OK) def logout(response: Response): """Clear the auth cookie.""" - _clear_auth_cookie(response) + clear_auth_cookie(response) return {"detail": "Logged out"} @@ -128,10 +67,10 @@ def register(body: RegisterRequest, response: Response, db: Session = Depends(ge """ check_if_email_exists(db, body.email) - user = _create_user(db, body.email, body.first_name, body.last_name, "user", body.phone, 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) + set_auth_cookie(response, token) return user @@ -145,7 +84,7 @@ def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: """ check_if_email_exists(db, body.email) - return _create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) + return create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) @@ -176,6 +115,6 @@ async def send_email_verification(user: User = Depends(get_current_user)): if user.email_verified: raise HTTPException(400, "Email already verified") - await _send_verification_email(user.email, user.id) + await send_signup_verification_email(user.email, user.id) return {"detail": "Verification email successfully sent"} \ No newline at end of file diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index f7cae812..82185a8e 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -65,6 +65,39 @@ def decode_access_token(token: str) -> Optional[int]: return None +# --------------------------------------------------------------------------- +# Auth cookie +# --------------------------------------------------------------------------- + +COOKIE_NAME = "access_token" +COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds + + +def set_auth_cookie(response, token: str) -> None: + settings = get_settings() + is_prod = settings.app_env == "production" + is_preview = settings.app_env == "preview" + response.set_cookie( + key=COOKIE_NAME, + value=token, + httponly=True, + secure=is_prod or is_preview, + samesite="none" if (is_prod or is_preview) else "lax", + max_age=COOKIE_MAX_AGE, + path="/", + domain=".ethanshih.com" if is_prod else None, + ) + + +def clear_auth_cookie(response) -> None: + settings = get_settings() + is_prod = settings.app_env == "production" + response.delete_cookie( + key=COOKIE_NAME, + path="/", + domain=".ethanshih.com" if is_prod else None, + ) + # --------------------------------------------------------------------------- # Verification tokens # Backs signup email verification, email-change, and password reset. diff --git a/backend/app/core/users.py b/backend/app/core/users.py index f6a6f8d3..3dc49c38 100644 --- a/backend/app/core/users.py +++ b/backend/app/core/users.py @@ -4,6 +4,7 @@ from typing import Optional from app.models.models import User +from app.core.auth import hash_password def check_if_email_exists(db: Session, email: str, exclude_user_id: Optional[int] = None): query = db.query(User).filter(User.email == email.lower()) @@ -21,4 +22,28 @@ 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 + +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: + 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 \ No newline at end of file diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 525a4246..f157de75 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,6 +1,8 @@ import resend +from fastapi import HTTPException from app.core.config import get_settings +from app.core.email_verification import generate_verification_token async def send_verification_email(to: str, token: str) -> None: @@ -14,4 +16,16 @@ async def send_verification_email(to: str, token: str) -> None: "text": f"Please verify your email: {settings.frontend_url.rstrip('/')}/verify-email?token={token}" } - await resend.Emails.send_async(params) \ No newline at end of file + await resend.Emails.send_async(params) + + +async def send_signup_verification_email(to: str, user_id: int) -> None: + """ + Sends the signup verification email. Currently still JWT-based via + app.core.email_verification — pending migration to the shared + verification_tokens flow. + """ + try: + await send_verification_email(to, generate_verification_token(user_id)) + except Exception: + raise HTTPException(500, "Failed to send verification email") \ No newline at end of file From 449c290a4a79e1b75bb6bd6aede57a7c8a5bf7fd Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 07:36:40 -0700 Subject: [PATCH 04/55] refactor(auth): removed core/email_verification helper module since the verification token helpers in core/auth alr do the same job --- backend/app/api/routes/auth.py | 22 +++++++++++---------- backend/app/core/email_verification.py | 27 -------------------------- backend/app/services/email_service.py | 17 ++++++++-------- backend/tests/api/test_auth.py | 12 ++++++------ 4 files changed, 27 insertions(+), 51 deletions(-) delete mode 100644 backend/app/core/email_verification.py diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index d33c5511..9ae11df1 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -11,7 +11,7 @@ clear_auth_cookie, ) from app.core.users import check_if_email_exists, find_user_by_id, create_user -from app.core.email_verification import verify_verification_token +from app.core.auth import consume_verification_token from app.db.session import get_db from app.models.models import User from app.schemas.user import UserSlimResponse @@ -91,30 +91,32 @@ def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: @router.get("/auth/verify-email/", status_code=status.HTTP_200_OK, response_model=MessageResponse, responses={ 400: {"description": "Invalid or expired token"}, - 404: {"description": "User not found"}, }, ) def verify_email(token: str, db: Session = Depends(get_db)): - user_id = verify_verification_token(token) - if user_id is None: + token_row = consume_verification_token(db, token, "signup_verify") + if token_row is None: raise HTTPException(400, "Invalid or expired token") - - user = find_user_by_id(db, user_id) + + user = find_user_by_id(db, token_row.user_id) user.email_verified = True db.commit() return {"detail": "User email successfully verified"} -# todo: add rate limiting @router.post("/auth/send-email-verification/", status_code=status.HTTP_200_OK, response_model=MessageResponse, responses={ 400: {"description": "Email already verified"}, + 429: {"description": "Verification email requested too recently"}, 500: {"description": "Failed to send verification email"}, }, ) -async def send_email_verification(user: User = Depends(get_current_user)): +async def send_email_verification( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): if user.email_verified: raise HTTPException(400, "Email already verified") - - await send_signup_verification_email(user.email, user.id) + + await send_signup_verification_email(db, user.email, user.id) return {"detail": "Verification email successfully sent"} \ No newline at end of file diff --git a/backend/app/core/email_verification.py b/backend/app/core/email_verification.py deleted file mode 100644 index ca4d8ed7..00000000 --- a/backend/app/core/email_verification.py +++ /dev/null @@ -1,27 +0,0 @@ -from datetime import datetime, timedelta, timezone -from typing import Optional - -from jose import JWTError, jwt - -from app.core.config import get_settings - -ALGORITHM = "HS256" -VERIFICATION_TOKEN_EXPIRE_DAYS = 1 - -def generate_verification_token(user_id: int) -> str: - settings = get_settings() - expire = datetime.now(timezone.utc) + timedelta(days=VERIFICATION_TOKEN_EXPIRE_DAYS) - payload = {"sub": str(user_id), "exp": expire, "aud": "email_verification"} - return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM) - -def verify_verification_token(token: str) -> Optional[int]: - """Returns user_id if valid token, or None if invalid/expired/wrong purpose""" - settings = get_settings() - try: - payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM], audience="email_verification") - user_id = payload.get("sub") - if user_id is None: - return None - return int(user_id) - except JWTError: - return None \ No newline at end of file diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index f157de75..571ab543 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,8 +1,9 @@ import resend from fastapi import HTTPException +from sqlalchemy.orm import Session from app.core.config import get_settings -from app.core.email_verification import generate_verification_token +from app.core.auth import create_verification_token, RateLimitedError async def send_verification_email(to: str, token: str) -> None: @@ -19,13 +20,13 @@ async def send_verification_email(to: str, token: str) -> None: await resend.Emails.send_async(params) -async def send_signup_verification_email(to: str, user_id: int) -> None: - """ - Sends the signup verification email. Currently still JWT-based via - app.core.email_verification — pending migration to the shared - verification_tokens flow. - """ +async def send_signup_verification_email(db: Session, to: str, user_id: int) -> None: try: - await send_verification_email(to, generate_verification_token(user_id)) + token = create_verification_token(db, user_id, "signup_verify") + except RateLimitedError as e: + raise HTTPException(429, str(e)) + + try: + await send_verification_email(to, token) except Exception: raise HTTPException(500, "Failed to send verification email") \ No newline at end of file diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index e0ff8a25..f22a4dba 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -2,8 +2,7 @@ import pytest from fastapi.testclient import TestClient from tests.conftest import login -from app.core.auth import hash_password -from app.core.email_verification import generate_verification_token +from app.core.auth import hash_password, create_verification_token from app.models.models import User @@ -349,7 +348,7 @@ def test_admin_register_invalid_role_rejected(self, client, admin_user): class TestVerifyEmail: def test_valid_token_verifies_email(self, client, td_user, db): assert td_user.email_verified is False - token = generate_verification_token(td_user.id) + token = create_verification_token(db, td_user.id, "signup_verify") res = client.get(f"/auth/verify-email/?token={token}") assert res.status_code == 200 db.refresh(td_user) @@ -358,9 +357,10 @@ def test_valid_token_verifies_email(self, client, td_user, db): def test_invalid_token_rejected(self, client): assert client.get("/auth/verify-email/?token=garbage").status_code == 400 - def test_token_for_nonexistent_user_not_found(self, client): - token = generate_verification_token(9999) - assert client.get(f"/auth/verify-email/?token={token}").status_code == 404 + def test_token_already_used_rejected(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "signup_verify") + assert client.get(f"/auth/verify-email/?token={token}").status_code == 200 + assert client.get(f"/auth/verify-email/?token={token}").status_code == 400 # --------------------------------------------------------------------------- From 3390aec418d382904781bf937521c86fc1ec0700 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 07:48:07 -0700 Subject: [PATCH 05/55] feat(auth): add email change, password change, and password reset schemas --- backend/app/schemas/auth.py | 120 +++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 35 deletions(-) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 0bc54454..e11d9c80 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, EmailStr, field_validator, model_validator from typing import Optional, Literal from datetime import datetime @@ -18,6 +18,46 @@ class LoginRequest(BaseModel): "valid": "Password contains an invalid character.\n" } + +def validate_password_strength(password: str) -> str: + """ + Shared password strength check — used by RegisterRequest, PasswordChangeRequest, + and PasswordResetConfirm. Raises ValueError with the same messages as before + if any check fails. + """ + # 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 RegisterRequest(BaseModel): email: EmailStr phone: str @@ -31,42 +71,10 @@ class RegisterRequest(BaseModel): def normalize_phone(cls, phone: str) -> str: return _normalize_phone(phone) - @field_validator("password") @classmethod def check_password(cls, password: str) -> str: - # must be all true to pass - checks: dict[str, bool] = {"length": False, "upper": False, "lower": False, "number": False, "symbol": False, "valid": True} - if len(password) >= 8: - checks["length"] = True - - for c in password: - value = ord(c) - if not checks["number"] and c.isdigit(): - checks["number"] = True - continue - if not checks["upper"] and c.isupper(): - checks["upper"] = True - continue - if not checks["lower"] and c.islower(): - checks["lower"] = True - continue - if not checks["symbol"] and (33 <= value <= 47 or 58 <= value <= 64 or 91 <= value <= 96 or 123 <= value <= 126): - checks["symbol"] = True - continue - if value <= 32 or value >= 127: - checks["valid"] = False - - if any(not value for value in checks.values()): - msg = "" - for key, value in checks.items(): - if not value: - msg += PASSWORD_ERROR_MSG[key] - - raise ValueError(msg) - - return password - + return validate_password_strength(password) class AdminRegisterRequest(BaseModel): @@ -79,4 +87,46 @@ class AdminRegisterRequest(BaseModel): class MessageResponse(BaseModel): - detail: str \ No newline at end of file + detail: str + + +# --------------------------------------------------------------------------- +# Account settings — email change, password change, password reset +# --------------------------------------------------------------------------- + +class EmailChangeRequest(BaseModel): + """POST /auth/email/request-change — authenticated.""" + new_email: EmailStr + + +class PasswordChangeRequest(BaseModel): + """POST /auth/password/change — authenticated.""" + current_password: str + new_password: str + + @field_validator("new_password") + @classmethod + def check_new_password(cls, password: str) -> str: + return validate_password_strength(password) + + @model_validator(mode="after") + def check_new_differs_from_current(self) -> "PasswordChangeRequest": + if self.current_password == self.new_password: + raise ValueError("New password must be different from current password.") + return self + + +class PasswordResetRequest(BaseModel): + """POST /auth/password/reset/request — logged out, by email.""" + email: EmailStr + + +class PasswordResetConfirm(BaseModel): + """POST /auth/password/reset/confirm — logged out, token + new password.""" + token: str + new_password: str + + @field_validator("new_password") + @classmethod + def check_new_password(cls, password: str) -> str: + return validate_password_strength(password) \ No newline at end of file From 49883756e58508ee8c0183e213f75b7c81c36a2a Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 08:13:51 -0700 Subject: [PATCH 06/55] feat(auth): add account setup schemas for users whose account was opened by admin so a password needs to be set --- backend/app/core/auth.py | 36 ++------------------------------ backend/app/schemas/auth.py | 41 ++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 82185a8e..85a2dd97 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -65,39 +65,6 @@ def decode_access_token(token: str) -> Optional[int]: return None -# --------------------------------------------------------------------------- -# Auth cookie -# --------------------------------------------------------------------------- - -COOKIE_NAME = "access_token" -COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds - - -def set_auth_cookie(response, token: str) -> None: - settings = get_settings() - is_prod = settings.app_env == "production" - is_preview = settings.app_env == "preview" - response.set_cookie( - key=COOKIE_NAME, - value=token, - httponly=True, - secure=is_prod or is_preview, - samesite="none" if (is_prod or is_preview) else "lax", - max_age=COOKIE_MAX_AGE, - path="/", - domain=".ethanshih.com" if is_prod else None, - ) - - -def clear_auth_cookie(response) -> None: - settings = get_settings() - is_prod = settings.app_env == "production" - response.delete_cookie( - key=COOKIE_NAME, - path="/", - domain=".ethanshih.com" if is_prod else None, - ) - # --------------------------------------------------------------------------- # Verification tokens # Backs signup email verification, email-change, and password reset. @@ -105,12 +72,13 @@ def clear_auth_cookie(response) -> None: # (VerificationToken.token_hash, via the same bcrypt context as passwords). # --------------------------------------------------------------------------- -Purpose = Literal["signup_verify", "email_change", "password_reset"] +Purpose = Literal["signup_verify", "email_change", "password_reset", "account_setup"] TOKEN_TTL: dict[Purpose, timedelta] = { "signup_verify": timedelta(hours=24), "email_change": timedelta(hours=24), "password_reset": timedelta(hours=1), + "account_setup": timedelta(days=7), # admin invites sit longer before expiring } RATE_LIMIT_WINDOW = timedelta(seconds=60) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index e11d9c80..f38082dd 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -129,4 +129,43 @@ class PasswordResetConfirm(BaseModel): @field_validator("new_password") @classmethod def check_new_password(cls, password: str) -> str: - return validate_password_strength(password) \ No newline at end of file + return validate_password_strength(password) + +class AccountSetupConfirm(BaseModel): + """ + POST /auth/account-setup/confirm — logged out. + + Consumes an 'account_setup' token (sent when an admin creates a user) and + completes the account: sets the initial password, collects phone (not + gathered at invite-time), and optionally lets the user correct the name + the admin entered. Mirrors sign-up's phase-1 fields, minus email (locked + to the invited address) and plus optional name correction. + + Does NOT set email_verified — that still requires the normal verification flow. + """ + token: str + password: str + phone: str + first_name: Optional[str] = None + last_name: Optional[str] = None + + @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: + return validate_password_strength(password) + + +class AccountSetupResendRequest(BaseModel): + """ + POST /admin/auth/account-setup/resend — admin only. + + Resends the account-setup invite for a specific user (by id) at the + admin's request. Not public — avoids any account-enumeration surface + on the signup page. + """ + user_id: int \ No newline at end of file From 51c64daf3c2f1386a256ab857e18fed64c65ad1e Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 09:36:29 -0700 Subject: [PATCH 07/55] feat(auth): add shared auth email html template for sign up verification, email change, password reset, and account setup --- backend/app/services/email_service.py | 295 +++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 5 deletions(-) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 571ab543..0f4675e9 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,25 +1,167 @@ import resend from fastapi import HTTPException from sqlalchemy.orm import Session +from typing import Optional from app.core.config import get_settings from app.core.auth import create_verification_token, RateLimitedError -async def send_verification_email(to: str, token: str) -> None: +# --------------------------------------------------------------------------- +# Shared HTML template +# +# Mirrors app/globals.css: Georgia for the wordmark/heading (matches h1/h2 +# on every frontend page), Geist for UI text/CTA, Geist Mono for body copy. +# Geist fonts are loaded from the same jsdelivr CDN the frontend uses via +# @font-face in — email clients that support web fonts (Apple Mail, +# some webmail) will render them; everything else falls back cleanly to +# system-ui / Courier New, same as the app's own font-family fallback chain. +# +# All layout styling stays inline — email clients strip +""" + + +def _render_email_html( + heading: str, + body_lines: list[str], + footnote: str, + cta_label: Optional[str] = None, + cta_url: Optional[str] = None, +) -> str: + body_html = "".join( + f'

{line}

' + for line in body_lines + ) + + cta_block = "" + if cta_url and cta_label: + cta_block = f"""\ + + + + +
+ {cta_label} +
+

+ If the button doesn't work, copy and paste this link:
+ {cta_url} +

+""" + + return f"""\ + + + + +{_FONT_FACES} + + + + + +
+ + + + + + + + + + +
+

NEXUS

+
+

{heading}

+ {body_html} +{cta_block}
+

{footnote}

+
+
+ + +""" + + +def _cta_url(path: str, token: Optional[str] = None) -> str: + settings = get_settings() + base = f"{settings.frontend_url.rstrip('/')}{path}" + return f"{base}?token={token}" if token else base + + +_CONTACT_SUPPORT = "If this wasn't you, please contact support." + + +async def _send(to: str, subject: str, text: str, html: str) -> None: settings = get_settings() resend.api_key = settings.resend_api_key - + params: resend.Emails.SendParams = { "from": "NEXUS ", "to": to, - "subject": "Verify Your Email on NEXUS", - "text": f"Please verify your email: {settings.frontend_url.rstrip('/')}/verify-email?token={token}" + "subject": subject, + "text": text, + "html": html, } await resend.Emails.send_async(params) +# --------------------------------------------------------------------------- +# Signup verification +# --------------------------------------------------------------------------- + +async def send_verification_email(to: str, token: str) -> None: + url = _cta_url("/verify-email", token) + html = _render_email_html( + heading="Verify your email", + body_lines=[ + "Thanks for signing up for NEXUS. Confirm this is your email address to finish setting up your account.", + ], + cta_label="Verify email", + cta_url=url, + footnote="This link expires in 24 hours. If you didn't create this account, please contact support right away.", + ) + await _send(to, "Verify your email on NEXUS", f"Please verify your email: {url}", html) + + async def send_signup_verification_email(db: Session, to: str, user_id: int) -> None: try: token = create_verification_token(db, user_id, "signup_verify") @@ -29,4 +171,147 @@ async def send_signup_verification_email(db: Session, to: str, user_id: int) -> try: await send_verification_email(to, token) except Exception: - raise HTTPException(500, "Failed to send verification email") \ No newline at end of file + raise HTTPException(500, "Failed to send verification email") + + +# --------------------------------------------------------------------------- +# Email change +# --------------------------------------------------------------------------- + +async def send_email_change_email(to_new_email: str, token: str) -> None: + url = _cta_url("/settings/account/confirm-email", token) + html = _render_email_html( + heading="Confirm your new email", + body_lines=[ + "You requested to change the email address on your NEXUS account to this address.", + "Confirm the change below. Your current email stays active until you do.", + ], + cta_label="Confirm new email", + cta_url=url, + footnote="This link expires in 24 hours. If you didn't request this change, please contact support.", + ) + await _send(to_new_email, "Confirm your new email on NEXUS", f"Confirm your new email: {url}", html) + + +async def send_email_change_request_email(db: Session, user_id: int, new_email: str) -> None: + try: + token = create_verification_token(db, user_id, "email_change", new_email=new_email) + except RateLimitedError as e: + raise HTTPException(429, str(e)) + + try: + await send_email_change_email(new_email, token) + except Exception: + raise HTTPException(500, "Failed to send email change confirmation") + + +async def send_email_changed_notice(old_email: str, new_email: str) -> None: + """ + Sent to the OLD email address once an email change completes — the + account owner may not be the one who initiated it, so this is the only + notice that reaches them if their account was compromised. + """ + url = _cta_url("/forgot-password") + html = _render_email_html( + heading="Your email address was changed", + body_lines=[ + f"The email on your NEXUS account was changed to {new_email}.", + "If you made this change, no action is needed.", + ], + cta_label="Secure your account", + cta_url=url, + footnote=_CONTACT_SUPPORT, + ) + await _send( + old_email, + "Your NEXUS account email was changed", + f"Your account email was changed to {new_email}. If this wasn't you, secure your account: {url}", + html, + ) + + +# --------------------------------------------------------------------------- +# Password reset +# --------------------------------------------------------------------------- + +async def send_password_reset_email(to: str, token: str) -> None: + url = _cta_url("/reset-password", token) + html = _render_email_html( + heading="Reset your password", + body_lines=[ + "We received a request to reset the password on your NEXUS account.", + "Choose a new password to regain access.", + ], + cta_label="Reset password", + cta_url=url, + footnote=f"This link expires in 1 hour. If you didn't request this, someone may be trying to access your account. {_CONTACT_SUPPORT}", + ) + await _send(to, "Reset your password on NEXUS", f"Reset your password: {url}", html) + + +async def send_password_reset_request_email(db: Session, user_id: int, to: str) -> None: + try: + token = create_verification_token(db, user_id, "password_reset") + except RateLimitedError as e: + raise HTTPException(429, str(e)) + + try: + await send_password_reset_email(to, token) + except Exception: + raise HTTPException(500, "Failed to send password reset email") + + +async def send_password_changed_notice(to: str) -> None: + """ + Sent after an authenticated password change (settings page), not the + forgot-password flow. Confirms the change to the account owner and + gives them a way back in if it wasn't actually them. + """ + url = _cta_url("/forgot-password") + html = _render_email_html( + heading="Your password was changed", + body_lines=[ + "The password on your NEXUS account was just changed.", + "If you made this change, no action is needed.", + ], + cta_label="Reset password", + cta_url=url, + footnote=_CONTACT_SUPPORT, + ) + await _send( + to, + "Your NEXUS password was changed", + f"Your password was just changed. If this wasn't you, reset it here: {url}", + html, + ) + + +# --------------------------------------------------------------------------- +# Account setup (admin-created invite) +# --------------------------------------------------------------------------- + +async def send_account_setup_email(to: str, token: str) -> None: + url = _cta_url("/account-setup", token) + html = _render_email_html( + heading="You've been added to NEXUS", + body_lines=[ + "An administrator created an account for you on NEXUS.", + "Set up your account to get started.", + ], + cta_label="Set up account", + cta_url=url, + footnote="This link expires in 7 days. If you weren't expecting this, contact your tournament administrator.", + ) + await _send(to, "You've been added to NEXUS", f"Set up your account: {url}", html) + + +async def send_account_setup_invite_email(db: Session, user_id: int, to: str) -> None: + try: + token = create_verification_token(db, user_id, "account_setup") + except RateLimitedError as e: + raise HTTPException(429, str(e)) + + try: + await send_account_setup_email(to, token) + except Exception: + raise HTTPException(500, "Failed to send account setup email") \ No newline at end of file From 9c2f99b02369efdae3211f5befe1e9776077f400 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 09:47:03 -0700 Subject: [PATCH 08/55] feat(auth): add new auth routes for email change, password change/reset, and account setup --- backend/app/api/routes/auth.py | 238 ++++++++++++++++++++++++++++++++- backend/app/core/auth.py | 33 +++++ 2 files changed, 264 insertions(+), 7 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 9ae11df1..f923eb8b 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -9,18 +9,40 @@ require_admin, set_auth_cookie, clear_auth_cookie, + consume_verification_token, ) from app.core.users import check_if_email_exists, find_user_by_id, create_user -from app.core.auth import consume_verification_token from app.db.session import get_db from app.models.models import User from app.schemas.user import UserSlimResponse -from app.schemas.auth import LoginRequest, RegisterRequest, AdminRegisterRequest, MessageResponse -from app.services.email_service import send_signup_verification_email +from app.schemas.auth import ( + LoginRequest, + RegisterRequest, + AdminRegisterRequest, + MessageResponse, + EmailChangeRequest, + PasswordChangeRequest, + PasswordResetRequest, + PasswordResetConfirm, + AccountSetupConfirm, + AccountSetupResendRequest, +) +from app.services.email_service import ( + send_signup_verification_email, + send_email_change_request_email, + send_email_changed_notice, + send_password_reset_request_email, + send_password_changed_notice, + send_account_setup_invite_email, +) router = APIRouter(tags=["auth"]) +# --------------------------------------------------------------------------- +# Login / Logout +# --------------------------------------------------------------------------- + @router.post("/auth/login/", response_model=UserSlimResponse) def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): """ @@ -59,6 +81,10 @@ def logout(response: Response): +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + @router.post("/auth/register/", response_model=UserSlimResponse, status_code=status.HTTP_201_CREATED) def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)): """ @@ -77,16 +103,23 @@ def register(body: RegisterRequest, response: Response, db: Session = Depends(ge @router.post("/admin/auth/register/", response_model=UserSlimResponse, status_code=status.HTTP_201_CREATED) -def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: User = Depends(require_admin)): +async 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. + to set their own via the account-setup invite email sent here. """ check_if_email_exists(db, body.email) - return create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) + user = create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) + await send_account_setup_invite_email(db, user.id, user.email) + + return user + +# --------------------------------------------------------------------------- +# Signup email verification +# --------------------------------------------------------------------------- @router.get("/auth/verify-email/", status_code=status.HTTP_200_OK, response_model=MessageResponse, responses={ @@ -94,6 +127,7 @@ def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_db), _: }, ) def verify_email(token: str, db: Session = Depends(get_db)): + """Consumes a signup_verify token and marks the user's email verified.""" token_row = consume_verification_token(db, token, "signup_verify") if token_row is None: raise HTTPException(400, "Invalid or expired token") @@ -114,9 +148,199 @@ async def send_email_verification( user: User = Depends(get_current_user), db: Session = Depends(get_db), ): + """Resends the signup verification email for the current user.""" if user.email_verified: raise HTTPException(400, "Email already verified") await send_signup_verification_email(db, user.email, user.id) - return {"detail": "Verification email successfully sent"} \ No newline at end of file + return {"detail": "Verification email successfully sent"} + + +# --------------------------------------------------------------------------- +# Email change +# --------------------------------------------------------------------------- + +@router.post("/auth/email/request-change/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 409: {"description": "Email already registered to another account"}, + 429: {"description": "Email change requested too recently"}, + 500: {"description": "Failed to send confirmation email"}, + }, +) +async def request_email_change( + body: EmailChangeRequest, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Sends a confirmation link to the NEW address. user.email is untouched + until that link is clicked — see confirm_email_change(). + """ + check_if_email_exists(db, body.new_email, exclude_user_id=user.id) + + await send_email_change_request_email(db, user.id, body.new_email) + + return {"detail": "Confirmation email sent to new address"} + + +@router.get("/auth/email/confirm-change/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 400: {"description": "Invalid or expired token"}, + }, +) +async def confirm_email_change(token: str, db: Session = Depends(get_db)): + """ + Clicking this link is itself proof of ownership of the new address, + so email_verified is set true here — no separate re-verification needed. + """ + token_row = consume_verification_token(db, token, "email_change") + if token_row is None: + raise HTTPException(400, "Invalid or expired token") + + user = find_user_by_id(db, token_row.user_id) + old_email = user.email + + user.email = token_row.new_email + user.email_verified = True + db.commit() + + await send_email_changed_notice(old_email, user.email) + + return {"detail": "Email successfully updated"} + + +# --------------------------------------------------------------------------- +# Password change (authenticated) / reset (logged out) +# --------------------------------------------------------------------------- + +@router.post("/auth/password/change/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 401: {"description": "Current password is incorrect"}, + }, +) +async def change_password( + body: PasswordChangeRequest, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Authenticated password change — requires current_password to match before setting new_password.""" + if not user.hashed_password or not verify_password(body.current_password, user.hashed_password): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Current password is incorrect") + + user.hashed_password = hash_password(body.new_password) + db.commit() + + await send_password_changed_notice(user.email) + + return {"detail": "Password successfully changed"} + + +@router.post("/auth/password/reset/request/", status_code=status.HTTP_200_OK, response_model=MessageResponse) +async def request_password_reset(body: PasswordResetRequest, db: Session = Depends(get_db)): + """ + Always returns the same generic response regardless of whether the + email matches an account — avoids account enumeration. Pending + admin-invited accounts (no password yet) are deliberately excluded: + they should use account-setup, not password reset. + """ + user = db.query(User).filter( + User.email == body.email.lower(), + User.is_active == True, + ).first() + + if user and user.hashed_password: + try: + await send_password_reset_request_email(db, user.id, user.email) + except HTTPException as e: + if e.status_code != 429: + raise + # Swallow rate-limit responses here too — surfacing a 429 vs. + # the generic 200 would itself leak whether the email exists. + + return {"detail": "If an account exists for this email, a reset link has been sent."} + + +@router.post("/auth/password/reset/confirm/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 400: {"description": "Invalid or expired token"}, + }, +) +async def confirm_password_reset(body: PasswordResetConfirm, db: Session = Depends(get_db)): + """Logged-out password reset — consumes a password_reset token and sets new_password.""" + token_row = consume_verification_token(db, body.token, "password_reset") + if token_row is None: + raise HTTPException(400, "Invalid or expired token") + + user = find_user_by_id(db, token_row.user_id) + user.hashed_password = hash_password(body.new_password) + db.commit() + + await send_password_changed_notice(user.email) + + return {"detail": "Password successfully reset"} + + +# --------------------------------------------------------------------------- +# Account setup (admin-created invite) +# --------------------------------------------------------------------------- + +@router.post("/auth/account-setup/confirm/", response_model=UserSlimResponse, status_code=status.HTTP_200_OK, + responses={ + 400: {"description": "Invalid or expired token"}, + }, +) +async def confirm_account_setup( + body: AccountSetupConfirm, + response: Response, + db: Session = Depends(get_db), +): + """ + Completes an admin-created account: sets the initial password, collects + phone (not gathered at invite-time), and optionally overwrites the + admin-entered name. Logs the user in immediately, same as register(), + since this mirrors sign-up's flow. + """ + token_row = consume_verification_token(db, body.token, "account_setup") + if token_row is None: + raise HTTPException(400, "Invalid or expired token") + + user = find_user_by_id(db, token_row.user_id) + + user.hashed_password = hash_password(body.password) + user.phone = body.phone + if body.first_name is not None: + user.first_name = body.first_name + if body.last_name is not None: + user.last_name = body.last_name + user.is_active = True + db.commit() + db.refresh(user) + + token = create_access_token(user.id) + set_auth_cookie(response, token) + + return user + + +@router.post("/admin/auth/account-setup/resend/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 400: {"description": "Account setup already completed"}, + 429: {"description": "Invite requested too recently"}, + 500: {"description": "Failed to send invite email"}, + }, +) +async def resend_account_setup( + body: AccountSetupResendRequest, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + """Admin-only — resends the account-setup invite for a pending (not yet activated) user.""" + user = find_user_by_id(db, body.user_id) + + if user.is_active or user.hashed_password: + raise HTTPException(400, "Account setup already completed") + + await send_account_setup_invite_email(db, user.id, user.email) + + return {"detail": "Invite resent"} \ No newline at end of file diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 85a2dd97..d21ccaf3 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -65,6 +65,39 @@ def decode_access_token(token: str) -> Optional[int]: return None +# --------------------------------------------------------------------------- +# Auth cookie +# --------------------------------------------------------------------------- + +COOKIE_NAME = "access_token" +COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds + + +def set_auth_cookie(response, token: str) -> None: + settings = get_settings() + is_prod = settings.app_env == "production" + is_preview = settings.app_env == "preview" + response.set_cookie( + key=COOKIE_NAME, + value=token, + httponly=True, + secure=is_prod or is_preview, + samesite="none" if (is_prod or is_preview) else "lax", + max_age=COOKIE_MAX_AGE, + path="/", + domain=".ethanshih.com" if is_prod else None, + ) + + +def clear_auth_cookie(response) -> None: + settings = get_settings() + is_prod = settings.app_env == "production" + response.delete_cookie( + key=COOKIE_NAME, + path="/", + domain=".ethanshih.com" if is_prod else None, + ) + # --------------------------------------------------------------------------- # Verification tokens # Backs signup email verification, email-change, and password reset. From 54925501138c2a34b517a6f90a129d4d0d52d553 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 09:57:20 -0700 Subject: [PATCH 09/55] feat(tests): mocked email service so that resend isn't called on every test --- backend/tests/conftest.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7febc2cb..6a4af60a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -25,7 +25,7 @@ from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session -from unittest.mock import MagicMock +from unittest.mock import MagicMock, AsyncMock from app.db.session import Base, get_db from app.models import models # noqa: F401 @@ -219,6 +219,19 @@ def mock_forms_service() -> MagicMock: # Test client # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def mock_send_email(monkeypatch): + """ + Stubs the actual Resend call so no test run consumes real email quota. + Patched at the source (email_service.send_verification_email) so every + higher-level sender (signup verify, email change, password reset, + account setup) is covered without needing its own mock. + """ + mock = AsyncMock() + monkeypatch.setattr("app.services.email_service.send_verification_email", mock) + return mock + + @pytest.fixture(scope="function") def client(db, mock_sheets_service, mock_forms_service): from app.main import app From 12d0bdf905779454394d74d6d8f2601f801187e4 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 10:05:33 -0700 Subject: [PATCH 10/55] feat(models): add sessions table to track user sessions and to revoke them --- .../8c1053c617c1_add_sessions_table.py | 42 ++++++ backend/app/models/models.py | 141 +++++++++++------- 2 files changed, 129 insertions(+), 54 deletions(-) create mode 100644 backend/alembic/versions/8c1053c617c1_add_sessions_table.py diff --git a/backend/alembic/versions/8c1053c617c1_add_sessions_table.py b/backend/alembic/versions/8c1053c617c1_add_sessions_table.py new file mode 100644 index 00000000..79cfb093 --- /dev/null +++ b/backend/alembic/versions/8c1053c617c1_add_sessions_table.py @@ -0,0 +1,42 @@ +"""add sessions table + +Revision ID: 8c1053c617c1 +Revises: 013b9d385bd2 +Create Date: 2026-07-27 10:00:13.901096 + +""" +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 = '8c1053c617c1' +down_revision: Union[str, None] = '013b9d385bd2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('user_agent', sa.String(length=255), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sessions_id'), 'sessions', ['id'], unique=False) + op.create_index(op.f('ix_sessions_token_hash'), 'sessions', ['token_hash'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_sessions_token_hash'), table_name='sessions') + op.drop_index(op.f('ix_sessions_id'), table_name='sessions') + op.drop_table('sessions') diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 74954679..2cefd025 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -23,6 +23,68 @@ def utcnow(): return datetime.now(timezone.utc) +# --------------------------------------------------------------------------- +# UserSession +# Backs authentication — replaces the previous stateless JWT so sessions can +# be listed and individually or collectively revoked (e.g. "log out +# everywhere" in account settings). +# +# token_hash uses a fast hash (SHA-256), not bcrypt — unlike VerificationToken, +# this gets checked on every authenticated request. The raw token is already +# high-entropy random, so slow adaptive hashing isn't needed here and would +# add unacceptable per-request latency. Lookup is a direct indexed equality +# match, not a loop-and-verify like consume_verification_token. +# +# Fixed 7-day expiration from creation — no sliding renewal. last_active_at +# is updated on a throttle (not every request), purely for the "active Xh +# ago" display in the settings device list — it's not used in expiration +# or validity checks, only revoked_at + expires_at are. +# --------------------------------------------------------------------------- +class UserSession(Base): + __tablename__ = "sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + token_hash = Column(String(64), nullable=False, unique=True, index=True) # SHA-256 hex digest + + user_agent = Column(String(255), nullable=True) + ip_address = Column(String(45), nullable=True) # long enough for IPv6 + + created_at = Column(DateTime(timezone=True), default=utcnow) + last_active_at = Column(DateTime(timezone=True), default=utcnow) + expires_at = Column(DateTime(timezone=True), nullable=False) + revoked_at = Column(DateTime(timezone=True), nullable=True) + + user = relationship("User") + +# --------------------------------------------------------------------------- +# Verification Token +# Backs signup email verification, email-change, and password reset. +# One raw token is emailed to the user; only its hash is stored here. +# +# purpose: "signup_verify" | "email_change" | "password_reset" +# new_email is only ever set for "email_change" rows. +# +# On create, any prior unconsumed row for the same (user_id, purpose) is +# marked used_at (stale-token guarding) — see app/core/verification_tokens.py. +# --------------------------------------------------------------------------- +class VerificationToken(Base): + __tablename__ = "verification_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + token_hash = Column(String(255), nullable=False, index=True, unique=True) + purpose = Column(String(32), nullable=False) # "signup_verify" | "email_change" | "password_reset" + new_email = Column(String(255), nullable=True) # only set for "email_change" + + expires_at = Column(DateTime(timezone=True), nullable=False) + used_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=utcnow) + + user = relationship("User") + # --------------------------------------------------------------------------- # User # Core identity — volunteers, TDs, and admins all live here. @@ -94,33 +156,6 @@ class User(Base): cascade="all, delete-orphan" ) -# --------------------------------------------------------------------------- -# Verification Token -# Backs signup email verification, email-change, and password reset. -# One raw token is emailed to the user; only its hash is stored here. -# -# purpose: "signup_verify" | "email_change" | "password_reset" -# new_email is only ever set for "email_change" rows. -# -# On create, any prior unconsumed row for the same (user_id, purpose) is -# marked used_at (stale-token guarding) — see app/core/verification_tokens.py. -# --------------------------------------------------------------------------- -class VerificationToken(Base): - __tablename__ = "verification_tokens" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - - token_hash = Column(String(255), nullable=False, index=True, unique=True) - purpose = Column(String(32), nullable=False) # "signup_verify" | "email_change" | "password_reset" - new_email = Column(String(255), nullable=True) # only set for "email_change" - - expires_at = Column(DateTime(timezone=True), nullable=False) - used_at = Column(DateTime(timezone=True), nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - - user = relationship("User") - # --------------------------------------------------------------------------- # Competition Experience # --------------------------------------------------------------------------- @@ -136,8 +171,6 @@ class UserCompetitionExperience(Base): user = relationship("User", back_populates="competition_experience") event = relationship("Event", back_populates="user_competition_experience") - - # --------------------------------------------------------------------------- # Volunteer Experience # @@ -345,30 +378,6 @@ def is_over_21(self) -> Optional[bool]: ) -# --------------------------------------------------------------------------- -# SheetConfig -# --------------------------------------------------------------------------- -class SheetConfig(Base): - __tablename__ = "sheet_configs" - - id = Column(Integer, primary_key=True, index=True) - tournament_id = Column( - Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False - ) - label = Column(String(255), nullable=False) - sheet_type = Column(String(64), nullable=False) - sheet_url = Column(Text, nullable=False) - spreadsheet_id = Column(String(255), nullable=False) - sheet_name = Column(String(255), nullable=False) - column_mappings = Column(JSON, nullable=False, default=dict) - is_active = Column(Boolean, default=True) - last_synced_at = Column(DateTime(timezone=True), nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) - - tournament = relationship("Tournament", back_populates="sheet_configs") - - # --------------------------------------------------------------------------- # Tournament Event # --------------------------------------------------------------------------- @@ -401,4 +410,28 @@ class TournamentEvent(Base): __table_args__ = ( UniqueConstraint("tournament_id", "name", "division", name="uq_tournament_event_division"), - ) \ No newline at end of file + ) + + +# --------------------------------------------------------------------------- +# SheetConfig +# --------------------------------------------------------------------------- +class SheetConfig(Base): + __tablename__ = "sheet_configs" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column( + Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False + ) + label = Column(String(255), nullable=False) + sheet_type = Column(String(64), nullable=False) + sheet_url = Column(Text, nullable=False) + spreadsheet_id = Column(String(255), nullable=False) + sheet_name = Column(String(255), nullable=False) + column_mappings = Column(JSON, nullable=False, default=dict) + is_active = Column(Boolean, default=True) + last_synced_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + tournament = relationship("Tournament", back_populates="sheet_configs") \ No newline at end of file From a2000262fc1e2e8740ba5e9dc6ebc2fc4414b415 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 10:35:09 -0700 Subject: [PATCH 11/55] feat(auth): remove JWT for sessions; add session helpers like revoke sessions --- backend/app/core/auth.py | 182 ++++++++++++++++++++++++++++++++------- 1 file changed, 152 insertions(+), 30 deletions(-) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index d21ccaf3..d152085d 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -9,18 +9,18 @@ Tournament-level permission checking lives in app/core/permissions.py. """ +import hashlib import secrets from datetime import datetime, timedelta, timezone from typing import Optional, Literal -from fastapi import Cookie, Depends, HTTPException, status -from jose import JWTError, jwt +from fastapi import Cookie, Depends, HTTPException, Request, Response, status from passlib.context import CryptContext from sqlalchemy.orm import Session from app.core.config import get_settings from app.db.session import get_db -from app.models.models import User, VerificationToken +from app.models.models import User, VerificationToken, UserSession # --------------------------------------------------------------------------- # Password hashing @@ -38,32 +38,126 @@ def verify_password(plain: str, hashed: str) -> bool: # --------------------------------------------------------------------------- -# JWT +# Sessions +# Replaces the previous stateless JWT — the access_token cookie now holds +# a random opaque session token (not a JWT). This trades a small amount of +# stateless-JWT convenience for the ability to actually revoke access in +# real time (a JWT stays valid until it naturally expires; nothing short +# of a DB-backed check can kill it early). +# +# token_hash uses SHA-256, not bcrypt — this gets checked on every single +# authenticated request, and the raw token is already high-entropy random, +# so slow adaptive hashing isn't needed and would add real per-request +# latency. Lookup is a direct indexed equality match, unlike +# consume_verification_token's loop-and-bcrypt-verify (fine there since +# verification tokens are rare; wrong here since sessions are constant). # --------------------------------------------------------------------------- -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_DAYS = 7 +SESSION_EXPIRE_DAYS = 7 # fixed from creation — no sliding renewal +SESSION_ACTIVITY_THROTTLE = timedelta(minutes=15) # last_active_at update granularity -def create_access_token(user_id: int) -> str: - settings = get_settings() - expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) - payload = {"sub": str(user_id), "exp": expire, "aud": "session"} - return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM) +def _hash_session_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode()).hexdigest() -def decode_access_token(token: str) -> Optional[int]: - """Returns user_id from a valid token, or None if invalid/expired.""" - settings = get_settings() - try: - payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM], audience="session") - user_id = payload.get("sub") - if user_id is None: - return None - return int(user_id) - except JWTError: +def get_client_ip(request: Request) -> Optional[str]: + """ + Prefers X-Forwarded-For (Render sits in front of the app), falls back + to the direct connection IP. Takes the first entry — the original + client — since X-Forwarded-For can be a comma-separated chain of proxies. + """ + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else None + + +def create_session( + db: Session, + user_id: int, + user_agent: Optional[str] = None, + ip_address: Optional[str] = None, +) -> str: + """ + Creates a new session and returns the raw token — this is the only + time it exists in plaintext; only its SHA-256 hash is persisted. + """ + now = datetime.now(timezone.utc) + raw_token = secrets.token_urlsafe(32) + + session_row = UserSession( + user_id=user_id, + token_hash=_hash_session_token(raw_token), + user_agent=user_agent, + ip_address=ip_address, + expires_at=now + timedelta(days=SESSION_EXPIRE_DAYS), + ) + db.add(session_row) + db.commit() + + return raw_token + + +def get_active_session(db: Session, raw_token: str) -> Optional[UserSession]: + """ + Looks up a session by its raw token. Returns the row if valid (not + revoked, not expired), else None. + + Updates last_active_at, but only if it's stale by + SESSION_ACTIVITY_THROTTLE or more — this field is display-only (the + settings device list's "active Xh ago"), not part of any validity + check, so it doesn't need writing on every request. + """ + now = datetime.now(timezone.utc) + token_hash = _hash_session_token(raw_token) + + session_row = db.query(UserSession).filter( + UserSession.token_hash == token_hash, + UserSession.revoked_at.is_(None), + UserSession.expires_at > now, + ).first() + + if session_row is None: return None + if session_row.last_active_at is None or (now - session_row.last_active_at) >= SESSION_ACTIVITY_THROTTLE: + session_row.last_active_at = now + db.commit() + + return session_row + + +def revoke_session(db: Session, session_row: UserSession) -> None: + """Revokes a single session — e.g. explicit logout of just this device.""" + session_row.revoked_at = datetime.now(timezone.utc) + db.commit() + + +def revoke_all_other_sessions(db: Session, user_id: int, keep_session_id: int) -> None: + """'Log out everywhere' — revokes every session for the user except the current one.""" + now = datetime.now(timezone.utc) + db.query(UserSession).filter( + UserSession.user_id == user_id, + UserSession.id != keep_session_id, + UserSession.revoked_at.is_(None), + ).update({"revoked_at": now}, synchronize_session=False) + db.commit() + + +def revoke_all_sessions(db: Session, user_id: int) -> None: + """ + Revokes EVERY session for the user, including whatever's currently + active — used for admin account-locking, where the point is immediate + total lockout, not preserving anyone's current session. + """ + now = datetime.now(timezone.utc) + db.query(UserSession).filter( + UserSession.user_id == user_id, + UserSession.revoked_at.is_(None), + ).update({"revoked_at": now}, synchronize_session=False) + db.commit() + # --------------------------------------------------------------------------- # Auth cookie @@ -73,7 +167,7 @@ def decode_access_token(token: str) -> Optional[int]: COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds -def set_auth_cookie(response, token: str) -> None: +def set_auth_cookie(response: Response, token: str) -> None: settings = get_settings() is_prod = settings.app_env == "production" is_preview = settings.app_env == "preview" @@ -89,7 +183,7 @@ def set_auth_cookie(response, token: str) -> None: ) -def clear_auth_cookie(response) -> None: +def clear_auth_cookie(response: Response) -> None: settings = get_settings() is_prod = settings.app_env == "production" response.delete_cookie( @@ -98,6 +192,7 @@ def clear_auth_cookie(response) -> None: domain=".ethanshih.com" if is_prod else None, ) + # --------------------------------------------------------------------------- # Verification tokens # Backs signup email verification, email-change, and password reset. @@ -214,13 +309,19 @@ def consume_verification_token( # FastAPI dependencies # --------------------------------------------------------------------------- -def get_current_user( +def get_current_session( access_token: Optional[str] = Cookie(default=None), db: Session = Depends(get_db), -) -> User: +) -> UserSession: """ - Reads JWT from the httpOnly 'access_token' cookie. - Raises 401 if missing, invalid, expired, or user inactive. + Reads the opaque session token from the httpOnly 'access_token' cookie + and resolves it to an active UserSession row. + Raises 401 if missing, invalid, expired, or revoked. + + Split out from get_current_user so routes that need the session itself + (e.g. "log out everywhere" needs to exclude the current session, the + settings device list needs to mark which one is current) can depend on + this directly instead of re-deriving it from the user. """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -229,12 +330,33 @@ def get_current_user( if not access_token: raise credentials_exception - user_id = decode_access_token(access_token) - if user_id is None: + session_row = get_active_session(db, access_token) + if session_row is None: raise credentials_exception + return session_row + + +def get_current_user( + session_row: UserSession = Depends(get_current_session), + db: Session = Depends(get_db), +) -> User: + """ + Resolves the current session to its User. + Raises 401 if the user no longer exists or is inactive. + + NOTE: the is_active check here will need updating once the planned + status field (active/invited/deactivated/locked) replaces the boolean — + deliberately not doing that in this pass to keep the session-auth + migration and the status-field migration as separate, reviewable steps. + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + user = db.query(User).filter( - User.id == user_id, + User.id == session_row.user_id, User.is_active == True, ).first() if user is None: From 28d9e81c084f3d50d3ce95333f65b2a21918765a Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 10:48:22 -0700 Subject: [PATCH 12/55] feat(auth): wire session-based auth into login/register/logout/account-setup - login, register, and confirm_account_setup now call create_session() and set the raw session token as the cookie, replacing the deleted create_access_token JWT helper - logout now revokes the session server-side instead of only clearing the cookie, so a leaked cookie can't be replayed after logout - chose to read the cookie and call get_active_session directly in the logout route rather than adding a get_current_session_optional dependency, since tolerating a missing/invalid cookie is only needed there --- backend/app/api/routes/auth.py | 56 ++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index f923eb8b..469c0069 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,8 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, Response, status +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status from sqlalchemy.orm import Session from app.core.auth import ( - create_access_token, + create_session, + get_active_session, + get_client_ip, + revoke_session, hash_password, verify_password, get_current_user, @@ -44,10 +49,10 @@ # --------------------------------------------------------------------------- @router.post("/auth/login/", response_model=UserSlimResponse) -def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)): +def login(body: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)): """ Authenticate with email + password. - Sets an httpOnly JWT cookie on success. + Sets an httpOnly session cookie on success. """ user = db.query(User).filter( User.email == body.email.lower(), @@ -67,15 +72,31 @@ def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) detail="Invalid email or password", ) - token = create_access_token(user.id) - set_auth_cookie(response, token) + raw_token = create_session( + db, user.id, + user_agent=request.headers.get("user-agent"), + ip_address=get_client_ip(request), + ) + set_auth_cookie(response, raw_token) return user @router.post("/auth/logout/", status_code=status.HTTP_200_OK) -def logout(response: Response): - """Clear the auth cookie.""" +def logout( + response: Response, + access_token: Optional[str] = Cookie(default=None), + db: Session = Depends(get_db), +): + """ + Revokes the current session (if the cookie still resolves to a valid + one) and clears the cookie either way — logout should never error just + because the session was already gone. + """ + if access_token: + session_row = get_active_session(db, access_token) + if session_row is not None: + revoke_session(db, session_row) clear_auth_cookie(response) return {"detail": "Logged out"} @@ -86,7 +107,7 @@ def logout(response: Response): # --------------------------------------------------------------------------- @router.post("/auth/register/", response_model=UserSlimResponse, status_code=status.HTTP_201_CREATED) -def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)): +def register(body: RegisterRequest, request: Request, response: Response, db: Session = Depends(get_db)): """ Public route to create a new user account. All registered users get role="user". @@ -95,8 +116,12 @@ def register(body: RegisterRequest, response: Response, db: Session = Depends(ge 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) + raw_token = create_session( + db, user.id, + user_agent=request.headers.get("user-agent"), + ip_address=get_client_ip(request), + ) + set_auth_cookie(response, raw_token) return user @@ -292,6 +317,7 @@ async def confirm_password_reset(body: PasswordResetConfirm, db: Session = Depen ) async def confirm_account_setup( body: AccountSetupConfirm, + request: Request, response: Response, db: Session = Depends(get_db), ): @@ -317,8 +343,12 @@ async def confirm_account_setup( db.commit() db.refresh(user) - token = create_access_token(user.id) - set_auth_cookie(response, token) + raw_token = create_session( + db, user.id, + user_agent=request.headers.get("user-agent"), + ip_address=get_client_ip(request), + ) + set_auth_cookie(response, raw_token) return user From 2421714a8b40d557728ce7329725cdf36ccbb2bf Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 10:58:35 -0700 Subject: [PATCH 13/55] feat(user): replace User.is_active boolean with status field - new migration adds status (active/invited/deactivated/locked), backfills from is_active + hashed_password, and drops is_active in the same migration - migration refuses to guess (raises) if it finds is_active=false with a password set, since no current code path produces that combination - updated every is_active call site: login, get_current_user, create_user, admin_register, confirm_account_setup, request_password_reset, resend_account_setup - AdminUserUpdate and UserSlimResponse schemas now expose status instead of is_active - test fixtures/assertions updated; inactive_user fixture (has password, was is_active=False) mapped to status='deactivated' as a placeholder pending Step 3's deactivate/lock design --- ...43a2_replace_user_is_active_with_status.py | 55 +++++++++++++++++++ backend/app/api/routes/auth.py | 10 ++-- backend/app/api/routes/users.py | 2 +- backend/app/core/auth.py | 9 +-- backend/app/core/users.py | 4 +- backend/app/db/init_db.py | 4 +- backend/app/models/models.py | 2 +- backend/app/schemas/user.py | 5 +- backend/tests/api/test_auth.py | 10 ++-- backend/tests/api/test_users.py | 10 ++-- backend/tests/conftest.py | 6 +- 11 files changed, 84 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/d10cb65643a2_replace_user_is_active_with_status.py diff --git a/backend/alembic/versions/d10cb65643a2_replace_user_is_active_with_status.py b/backend/alembic/versions/d10cb65643a2_replace_user_is_active_with_status.py new file mode 100644 index 00000000..c10c5310 --- /dev/null +++ b/backend/alembic/versions/d10cb65643a2_replace_user_is_active_with_status.py @@ -0,0 +1,55 @@ +"""replace user is_active with status + +Revision ID: d10cb65643a2 +Revises: 8c1053c617c1 +Create Date: 2026-07-27 10:51:09.714544 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd10cb65643a2' +down_revision: Union[str, None] = '8c1053c617c1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('status', sa.String(length=32), nullable=True)) + + conn = op.get_bind() + + # is_active=false with a password set has no producing code path today — + # its correct status is ambiguous (deactivated? locked?), so refuse to + # guess and halt instead. + ambiguous = conn.execute(sa.text( + "SELECT id FROM users WHERE is_active = false AND hashed_password IS NOT NULL" + )).fetchall() + if ambiguous: + raise RuntimeError( + f"Cannot backfill status: {len(ambiguous)} user(s) have is_active=false " + f"with a password set (ids: {[r[0] for r in ambiguous]}). No existing code " + "path produces this combination, so the correct status can't be inferred " + "automatically — resolve manually before re-running this migration." + ) + + conn.execute(sa.text("UPDATE users SET status = 'active' WHERE is_active = true")) + conn.execute(sa.text("UPDATE users SET status = 'invited' WHERE is_active = false")) + + op.alter_column('users', 'status', nullable=False) + op.drop_column('users', 'is_active') + + +def downgrade() -> None: + op.add_column('users', sa.Column('is_active', sa.Boolean(), nullable=True)) + + conn = op.get_bind() + conn.execute(sa.text("UPDATE users SET is_active = true WHERE status = 'active'")) + conn.execute(sa.text("UPDATE users SET is_active = false WHERE status != 'active'")) + + op.alter_column('users', 'is_active', nullable=False) + op.drop_column('users', 'status') diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 469c0069..06951721 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -56,7 +56,7 @@ def login(body: LoginRequest, request: Request, response: Response, db: Session """ user = db.query(User).filter( User.email == body.email.lower(), - User.is_active == True, + User.status == "active", ).first() # Deliberate: same error whether email or password is wrong — prevents enumeration @@ -135,7 +135,7 @@ async def admin_register(body: AdminRegisterRequest, db: Session = Depends(get_d """ check_if_email_exists(db, body.email) - user = create_user(db, body.email, body.first_name, body.last_name, body.role, is_active=False) + user = create_user(db, body.email, body.first_name, body.last_name, body.role, status="invited") await send_account_setup_invite_email(db, user.id, user.email) return user @@ -271,7 +271,7 @@ async def request_password_reset(body: PasswordResetRequest, db: Session = Depen """ user = db.query(User).filter( User.email == body.email.lower(), - User.is_active == True, + User.status == "active", ).first() if user and user.hashed_password: @@ -339,7 +339,7 @@ async def confirm_account_setup( user.first_name = body.first_name if body.last_name is not None: user.last_name = body.last_name - user.is_active = True + user.status = "active" db.commit() db.refresh(user) @@ -368,7 +368,7 @@ async def resend_account_setup( """Admin-only — resends the account-setup invite for a pending (not yet activated) user.""" user = find_user_by_id(db, body.user_id) - if user.is_active or user.hashed_password: + if user.status != "invited": raise HTTPException(400, "Account setup already completed") await send_account_setup_invite_email(db, user.id, user.email) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index b238b72a..c9c54865 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -70,7 +70,7 @@ def admin_update_user( db: Session = Depends(get_db), _: User = Depends(require_admin) ): - """Admin can only update a user's role and is_active status.""" + """Admin can only update a user's role and status.""" user = find_user_by_id(db, user_id) for field, value in body.model_dump(exclude_unset=True).items(): setattr(user, field, value) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index d152085d..cc3af96d 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -343,12 +343,7 @@ def get_current_user( ) -> User: """ Resolves the current session to its User. - Raises 401 if the user no longer exists or is inactive. - - NOTE: the is_active check here will need updating once the planned - status field (active/invited/deactivated/locked) replaces the boolean — - deliberately not doing that in this pass to keep the session-auth - migration and the status-field migration as separate, reviewable steps. + Raises 401 if the user no longer exists or isn't active. """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -357,7 +352,7 @@ def get_current_user( user = db.query(User).filter( User.id == session_row.user_id, - User.is_active == True, + User.status == "active", ).first() if user is None: raise credentials_exception diff --git a/backend/app/core/users.py b/backend/app/core/users.py index 3dc49c38..1297f79a 100644 --- a/backend/app/core/users.py +++ b/backend/app/core/users.py @@ -32,7 +32,7 @@ def create_user( role: str, phone: Optional[str] = None, password: Optional[str] = None, - is_active: bool = True, + status: str = "active", ) -> User: user = User( email=email.lower(), @@ -41,7 +41,7 @@ def create_user( first_name=first_name, last_name=last_name, role=role, - is_active=is_active, + status=status, ) db.add(user) db.commit() diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 776621aa..bdc72be1 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -50,7 +50,7 @@ def seed_dev_data(db: Session) -> None: first_name="Admin", last_name="User", role="admin", - is_active=True, + status="active", ) db.add(admin) @@ -63,7 +63,7 @@ def seed_dev_data(db: Session) -> None: first_name="Tournament", last_name="Director", role="user", - is_active=True, + status="active", ) db.add(td) db.flush() # get IDs before creating tournament + memberships diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 2cefd025..33446da2 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -115,7 +115,7 @@ class User(Base): hashed_password = Column(String(255), nullable=True) # null = cannot log in, must reset password and verify via email email_verified = Column(Boolean, nullable=False, default=False) role = Column(String(32), nullable=False, default="user") # "admin" | "user" - is_active = Column(Boolean, nullable=False, default=True) + status = Column(String(32), nullable=False, default="active") # "active" | "invited" | "deactivated" | "locked" # if a student university = Column(String(255), nullable=True) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 53f9fb9a..6477c06f 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -8,6 +8,7 @@ ROLE = Literal["admin", "user"] +USER_STATUS = Literal["active", "invited", "deactivated", "locked"] STUDENT_STATUS = Literal["Undergraduate", "Graduate", "Non-Student"] @@ -48,7 +49,7 @@ def normalize_phone(cls, v: Optional[str]) -> str | None: class AdminUserUpdate(BaseModel): role: Optional[Literal["user", "admin"]] = None - is_active: Optional[bool] = None + status: Optional[USER_STATUS] = None @@ -62,7 +63,7 @@ class UserSlimResponse(BaseModel): email_verified: bool role: ROLE - is_active: bool + status: USER_STATUS created_at: datetime updated_at: datetime diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index f22a4dba..a5a913fa 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -14,7 +14,7 @@ def inactive_user(db): first_name="Inactive", last_name="User", role="user", - is_active=False, + status="deactivated", ) db.add(user) db.commit() @@ -29,7 +29,7 @@ def volunteer_no_password(db): first_name="Volunteer", last_name="NoPassword", role="user", - is_active=True, + status="active", ) db.add(user) db.commit() @@ -135,7 +135,7 @@ def test_register_success(self, client): data = res.json() assert data["email"] == "new@test.com" assert data["role"] == "user" - assert data["is_active"] == True + assert data["status"] == "active" assert "hashed_password" not in data def test_register_sets_cookie(self, client): @@ -281,7 +281,7 @@ def test_admin_can_create_admin(self, client, admin_user): 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 + # Accounts created by admin are "invited" until the user activates via email login(client, "admin@test.com", "adminpass") res = client.post("/admin/auth/register/", json={ "email": "newuser@test.com", @@ -290,7 +290,7 @@ def test_admin_created_user_is_inactive(self, client, admin_user): "role": "user", }) assert res.status_code == 201 - assert res.json()["is_active"] == False + assert res.json()["status"] == "invited" def test_admin_created_user_cannot_login(self, client, admin_user): # Inactive + no password — login must be blocked diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index 1a193415..24532942 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -17,7 +17,7 @@ def _db_user(db, email="alice@example.com", **kwargs): "email": email, "hashed_password": hash_password("Password@1"), "role": "user", - "is_active": True, + "status": "active", } defaults.update(kwargs) user = User(**defaults) @@ -93,7 +93,7 @@ def test_unauthenticated_forbidden(self, client, db): # --------------------------------------------------------------------------- -# PATCH /admin/users/{id} — admin only, role + is_active only +# PATCH /admin/users/{id} — admin only, role + status only # --------------------------------------------------------------------------- class TestAdminUpdateUser: @@ -107,15 +107,15 @@ def test_admin_can_change_role(self, client, admin_user, db): 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}) + res = client.patch(f"/admin/users/{alice.id}/", json={"status": "deactivated"}) assert res.status_code == 200 - assert res.json()["is_active"] == False + assert res.json()["status"] == "deactivated" 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}) + client.patch(f"/admin/users/{alice.id}/", json={"status": "deactivated"}) assert login(client, "alice@example.com", "Password@1").status_code == 401 def test_invalid_role_rejected(self, client, admin_user, db): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6a4af60a..aa9c215c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -79,7 +79,7 @@ def admin_user(db): first_name="Admin", last_name="User", role="admin", - is_active=True, + status="active", ) db.add(user) db.commit() @@ -95,7 +95,7 @@ def td_user(db): first_name="TD", last_name="User", role="user", - is_active=True, + status="active", ) db.add(user) db.commit() @@ -111,7 +111,7 @@ def other_user(db): first_name="Other", last_name="User", role="user", - is_active=True, + status="active", ) db.add(user) db.commit() From 6355464f57fb7741ad9db71f5cc3eeee8d4a616b Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 14:16:09 -0700 Subject: [PATCH 14/55] feat(user): revoke sessions when admin locks a user account - PATCH /admin/users/{id} now calls revoke_all_sessions when status is set to 'locked', so locking cuts off already-logged-in devices immediately instead of only blocking future logins - reused the existing generic admin-update endpoint rather than adding a dedicated /lock route, since status was already a field on it - admin recovery feature dropped per discussion (low-likelihood scenario, no clean way to verify identity once email access is lost) - added test_locking_revokes_existing_session covering the session-invalidation behavior --- backend/app/api/routes/users.py | 17 ++++++++++++++--- backend/tests/api/test_users.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index c9c54865..3408baf1 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session, selectinload from typing import Union -from app.core.auth import get_current_user, require_admin +from app.core.auth import get_current_user, require_admin, revoke_all_sessions from app.core.users import check_if_email_exists, find_user_by_id from app.core.profile_status import compute_missing_profile_fields, is_profile_complete from app.db.session import get_db @@ -70,10 +70,21 @@ def admin_update_user( db: Session = Depends(get_db), _: User = Depends(require_admin) ): - """Admin can only update a user's role and status.""" + """ + Admin can only update a user's role and status. + + Setting status="locked" also revokes every session for that user — + locking is meant to cut off access immediately, not just block future + logins, so a currently-logged-in device shouldn't stay usable. + """ user = find_user_by_id(db, user_id) - for field, value in body.model_dump(exclude_unset=True).items(): + updates = body.model_dump(exclude_unset=True) + for field, value in updates.items(): setattr(user, field, value) + + if updates.get("status") == "locked": + revoke_all_sessions(db, user.id) + db.commit() db.refresh(user) return user diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index 24532942..f562aa2e 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -123,6 +123,22 @@ def test_invalid_role_rejected(self, client, admin_user, db): login(client, "admin@test.com", "adminpass") assert client.patch(f"/admin/users/{alice.id}/", json={"role": "superuser"}).status_code == 422 + def test_locking_revokes_existing_session(self, client, admin_user, db): + # Locking must cut off an already-logged-in device immediately, not + # just block future logins the way plain deactivation does. + alice = _db_user(db, email="alice@example.com") + login(client, "alice@example.com", "Password@1") + alice_cookie = client.cookies.get("access_token") + assert client.get("/users/me/").status_code == 200 + + login(client, "admin@test.com", "adminpass") + res = client.patch(f"/admin/users/{alice.id}/", json={"status": "locked"}) + assert res.status_code == 200 + assert res.json()["status"] == "locked" + + client.cookies.set("access_token", alice_cookie) + assert client.get("/users/me/").status_code == 401 + def test_non_admin_forbidden(self, client, td_user, db): alice = _db_user(db) login(client, "td@test.com", "tdpass") From 3a47558019147c2386f8f72520bd2dec1568489e Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 14:35:59 -0700 Subject: [PATCH 15/55] feat(user): add self-service account deactivate and delete routes --- backend/app/api/routes/users.py | 63 +++++++++++++++++++++++++-- backend/app/schemas/auth.py | 27 +++++++++++- backend/tests/api/test_users.py | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 3408baf1..c6bbff5b 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,9 +1,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session, selectinload from typing import Union -from app.core.auth import get_current_user, require_admin, revoke_all_sessions +from app.core.auth import get_current_user, require_admin, revoke_all_sessions, verify_password, clear_auth_cookie from app.core.users import check_if_email_exists, find_user_by_id from app.core.profile_status import compute_missing_profile_fields, is_profile_complete from app.db.session import get_db @@ -12,6 +12,7 @@ UserFullResponse, UserMeFullResponse, UserSlimResponse, UserMeSlimResponse, UserUpdate, AdminUserUpdate ) +from app.schemas.auth import MessageResponse, AccountDeactivateRequest, AccountDeleteRequest router = APIRouter(tags=["users"]) @@ -155,7 +156,61 @@ def update_user_me( setattr(user, field, value) db.commit() db.refresh(user) - + response = UserMeFullResponse.model_validate(user) response.missing_profile_fields = compute_missing_profile_fields(user, db=db) - return response \ No newline at end of file + return response + + +# --------------------------------------------------------------------------- +# POST /users/me/deactivate/ — authenticated self-service deactivation +# --------------------------------------------------------------------------- +@router.post("/users/me/deactivate/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={401: {"description": "Current password is incorrect"}}, +) +def deactivate_me( + body: AccountDeactivateRequest, + response: Response, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Reversible self-deactivation. Revokes every session for the user + (including the one making this request) and clears the cookie on this + response so the client isn't left holding a dead token. + """ + if not user.hashed_password or not verify_password(body.password, user.hashed_password): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Current password is incorrect") + + user.status = "deactivated" + revoke_all_sessions(db, user.id) + db.commit() + + clear_auth_cookie(response) + return {"detail": "Account deactivated"} + + +# --------------------------------------------------------------------------- +# DELETE /users/me/ — authenticated self-service hard delete +# --------------------------------------------------------------------------- +@router.delete("/users/me/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={401: {"description": "Current password is incorrect"}}, +) +def delete_me( + body: AccountDeleteRequest, + response: Response, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Irreversible hard delete — cascades through TournamentMembership, + sessions, verification tokens, etc. via DB-level ON DELETE CASCADE. + """ + if not user.hashed_password or not verify_password(body.password, user.hashed_password): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Current password is incorrect") + + db.delete(user) + db.commit() + + clear_auth_cookie(response) + return {"detail": "Account successfully deleted"} \ No newline at end of file diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f38082dd..5746b128 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -163,9 +163,32 @@ def check_password(cls, password: str) -> str: class AccountSetupResendRequest(BaseModel): """ POST /admin/auth/account-setup/resend — admin only. - + Resends the account-setup invite for a specific user (by id) at the admin's request. Not public — avoids any account-enumeration surface on the signup page. """ - user_id: int \ No newline at end of file + user_id: int + + +# --------------------------------------------------------------------------- +# Self-service deactivate / delete +# --------------------------------------------------------------------------- + +class AccountDeactivateRequest(BaseModel): + """ + POST /users/me/deactivate — authenticated. + + Reversible — sets status="deactivated" and revokes every session. + """ + password: str + + +class AccountDeleteRequest(BaseModel): + """ + DELETE /users/me — authenticated. + + Irreversible hard delete — cascades through TournamentMembership and + everything else owned by the user. + """ + password: str \ No newline at end of file diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index f562aa2e..dd3e0fcd 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -172,6 +172,81 @@ def test_unauthenticated_forbidden(self, client): assert client.delete("/admin/users/1/").status_code == 401 +# --------------------------------------------------------------------------- +# POST /users/me/deactivate/ — authenticated self-service deactivation +# --------------------------------------------------------------------------- + +class TestDeactivateMe: + def test_deactivate_success(self, client, td_user, db): + login(client, "td@test.com", "tdpass") + res = client.post("/users/me/deactivate/", json={"password": "tdpass"}) + assert res.status_code == 200 + db.refresh(td_user) + assert td_user.status == "deactivated" + + def test_wrong_password_rejected(self, client, td_user, db): + login(client, "td@test.com", "tdpass") + res = client.post("/users/me/deactivate/", json={"password": "wrongpass"}) + assert res.status_code == 401 + db.refresh(td_user) + assert td_user.status == "active" + + def test_deactivate_revokes_session(self, client, td_user): + # The session used to make this request should itself be dead + # afterward, not just future logins blocked. + login(client, "td@test.com", "tdpass") + assert client.post("/users/me/deactivate/", json={"password": "tdpass"}).status_code == 200 + assert client.get("/users/me/").status_code == 401 + + def test_deactivated_user_cannot_login(self, client, td_user): + login(client, "td@test.com", "tdpass") + client.post("/users/me/deactivate/", json={"password": "tdpass"}) + assert login(client, "td@test.com", "tdpass").status_code == 401 + + def test_unauthenticated_forbidden(self, client): + assert client.post("/users/me/deactivate/", json={"password": "whatever"}).status_code == 401 + + +# --------------------------------------------------------------------------- +# DELETE /users/me/ — authenticated self-service hard delete +# --------------------------------------------------------------------------- + +class TestDeleteMe: + def test_delete_success(self, client, td_user, db): + login(client, "td@test.com", "tdpass") + res = client.request("DELETE", "/users/me/", json={"password": "tdpass"}) + assert res.status_code == 200 + assert db.query(User).filter(User.id == td_user.id).first() is None + + def test_wrong_password_rejected(self, client, td_user, db): + login(client, "td@test.com", "tdpass") + res = client.request("DELETE", "/users/me/", json={"password": "wrongpass"}) + assert res.status_code == 401 + assert db.query(User).filter(User.id == td_user.id).first() is not None + + def test_delete_cascades_membership(self, client, admin_user, td_tournament, db): + # admin_user here is a plain member, not the tournament owner — a + # user who owns a tournament can't be hard-deleted yet (owner_id is + # NOT NULL with no cascade rule defined); tracked separately in + # docs/deferred-items.md rather than handled by this route. + from app.models.models import TournamentMembership + db.add(TournamentMembership( + user_id=admin_user.id, + tournament_id=td_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + + login(client, "admin@test.com", "adminpass") + assert db.query(TournamentMembership).filter(TournamentMembership.user_id == admin_user.id).count() > 0 + assert client.request("DELETE", "/users/me/", json={"password": "adminpass"}).status_code == 200 + assert db.query(TournamentMembership).filter(TournamentMembership.user_id == admin_user.id).count() == 0 + + def test_unauthenticated_forbidden(self, client): + assert client.request("DELETE", "/users/me/", json={"password": "whatever"}).status_code == 401 + + # --------------------------------------------------------------------------- # GET /users/me/ (default, no ?full) — slim shape, folded in from old /auth/me/ # --------------------------------------------------------------------------- From 1b3b8760cdd53114a203913c0000119979d30b84 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 14:41:47 -0700 Subject: [PATCH 16/55] fix(tests): ensure tests aren't sending actual emails --- backend/app/services/email_service.py | 2 +- backend/tests/conftest.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 0f4675e9..644647c9 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -300,7 +300,7 @@ async def send_account_setup_email(to: str, token: str) -> None: ], cta_label="Set up account", cta_url=url, - footnote="This link expires in 7 days. If you weren't expecting this, contact your tournament administrator.", + footnote="This link expires in 7 days. If you weren't expecting this, contact support.", ) await _send(to, "You've been added to NEXUS", f"Set up your account: {url}", html) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aa9c215c..6695df6d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -223,12 +223,13 @@ def mock_forms_service() -> MagicMock: def mock_send_email(monkeypatch): """ Stubs the actual Resend call so no test run consumes real email quota. - Patched at the source (email_service.send_verification_email) so every - higher-level sender (signup verify, email change, password reset, - account setup) is covered without needing its own mock. + Patched at _send() — the one low-level function every sender (signup + verify, email change, password reset, account setup, etc.) funnels + through — so new senders are covered automatically without needing + their own mock. """ mock = AsyncMock() - monkeypatch.setattr("app.services.email_service.send_verification_email", mock) + monkeypatch.setattr("app.services.email_service._send", mock) return mock From cd83abf76705fc1d422f2779f89280cae5959627 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 14:49:09 -0700 Subject: [PATCH 17/55] feat(user): add session-facing settings endpoints --- backend/app/api/routes/users.py | 60 +++++++++++++++++++++++++++++++-- backend/app/schemas/session.py | 16 +++++++++ backend/tests/api/test_users.py | 50 +++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 backend/app/schemas/session.py diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index c6bbff5b..d5b83cf9 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,18 +1,23 @@ from __future__ import annotations +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session, selectinload from typing import Union -from app.core.auth import get_current_user, require_admin, revoke_all_sessions, verify_password, clear_auth_cookie +from app.core.auth import ( + get_current_user, require_admin, revoke_all_sessions, verify_password, clear_auth_cookie, + get_current_session, revoke_all_other_sessions, +) from app.core.users import check_if_email_exists, find_user_by_id from app.core.profile_status import compute_missing_profile_fields, is_profile_complete from app.db.session import get_db -from app.models.models import User, UserCompetitionExperience, UserVolunteerExperience, Event +from app.models.models import User, UserCompetitionExperience, UserVolunteerExperience, Event, UserSession from app.schemas.user import ( UserFullResponse, UserMeFullResponse, UserSlimResponse, UserMeSlimResponse, UserUpdate, AdminUserUpdate ) from app.schemas.auth import MessageResponse, AccountDeactivateRequest, AccountDeleteRequest +from app.schemas.session import SessionResponse router = APIRouter(tags=["users"]) @@ -213,4 +218,53 @@ def delete_me( db.commit() clear_auth_cookie(response) - return {"detail": "Account successfully deleted"} \ No newline at end of file + return {"detail": "Account successfully deleted"} + + +# --------------------------------------------------------------------------- +# GET /users/me/sessions/ — list the current user's active sessions +# --------------------------------------------------------------------------- +@router.get("/users/me/sessions/", response_model=list[SessionResponse]) +def list_my_sessions( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), + current_session: UserSession = Depends(get_current_session), +): + """Lists active (not revoked, not expired) sessions, most recently active first.""" + now = datetime.now(timezone.utc) + sessions = ( + db.query(UserSession) + .filter( + UserSession.user_id == user.id, + UserSession.revoked_at.is_(None), + UserSession.expires_at > now, + ) + .order_by(UserSession.last_active_at.desc()) + .all() + ) + + return [ + SessionResponse( + id=s.id, + user_agent=s.user_agent, + ip_address=s.ip_address, + created_at=s.created_at, + last_active_at=s.last_active_at, + is_current=(s.id == current_session.id), + ) + for s in sessions + ] + + +# --------------------------------------------------------------------------- +# POST /users/me/sessions/logout-others/ — "log out everywhere" except here +# --------------------------------------------------------------------------- +@router.post("/users/me/sessions/logout-others/", status_code=status.HTTP_200_OK, response_model=MessageResponse) +def logout_other_sessions( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), + current_session: UserSession = Depends(get_current_session), +): + """Revokes every session for the user except the one making this request.""" + revoke_all_other_sessions(db, user.id, current_session.id) + return {"detail": "Logged out of all other sessions"} \ No newline at end of file diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py new file mode 100644 index 00000000..2ff03824 --- /dev/null +++ b/backend/app/schemas/session.py @@ -0,0 +1,16 @@ +from __future__ import annotations +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class SessionResponse(BaseModel): + """GET /users/me/sessions/ — one row per active session.""" + id: int + user_agent: Optional[str] = None + ip_address: Optional[str] = None + created_at: datetime + last_active_at: Optional[datetime] = None + is_current: bool + + model_config = {"from_attributes": True} diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index dd3e0fcd..280919e5 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -247,6 +247,56 @@ def test_unauthenticated_forbidden(self, client): assert client.request("DELETE", "/users/me/", json={"password": "whatever"}).status_code == 401 +# --------------------------------------------------------------------------- +# GET /users/me/sessions/ — list active sessions +# --------------------------------------------------------------------------- + +class TestListMySessions: + def test_lists_active_session_with_current_flag(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.get("/users/me/sessions/") + assert res.status_code == 200 + sessions = res.json() + assert len(sessions) == 1 + assert sessions[0]["is_current"] is True + + def test_second_login_creates_separate_session(self, client, td_user): + login(client, "td@test.com", "tdpass") + login(client, "td@test.com", "tdpass") # simulates a second device + res = client.get("/users/me/sessions/") + sessions = res.json() + assert len(sessions) == 2 + assert sum(s["is_current"] for s in sessions) == 1 + + def test_unauthenticated_forbidden(self, client): + assert client.get("/users/me/sessions/").status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /users/me/sessions/logout-others/ — "log out everywhere" except here +# --------------------------------------------------------------------------- + +class TestLogoutOtherSessions: + def test_revokes_other_sessions_keeps_current(self, client, td_user): + login(client, "td@test.com", "tdpass") + first_cookie = client.cookies.get("access_token") + + login(client, "td@test.com", "tdpass") # second device, now current + second_cookie = client.cookies.get("access_token") + + res = client.post("/users/me/sessions/logout-others/") + assert res.status_code == 200 + + client.cookies.set("access_token", first_cookie) + assert client.get("/users/me/").status_code == 401 + + client.cookies.set("access_token", second_cookie) + assert client.get("/users/me/").status_code == 200 + + def test_unauthenticated_forbidden(self, client): + assert client.post("/users/me/sessions/logout-others/").status_code == 401 + + # --------------------------------------------------------------------------- # GET /users/me/ (default, no ?full) — slim shape, folded in from old /auth/me/ # --------------------------------------------------------------------------- From 0698bd2e18b1284aa2bc6367a17c32b026d93a47 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 27 Jul 2026 14:53:53 -0700 Subject: [PATCH 18/55] chore: remove unused python-jose dependency --- backend/app/core/auth.py | 2 +- backend/requirements.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index cc3af96d..c21cdbea 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -2,7 +2,7 @@ Core auth utilities. - Password hashing via bcrypt (passlib) -- JWT creation/decoding via python-jose +- Sessions (opaque DB-backed tokens, replacing the previous JWT scheme) - Verification tokens (signup verify / email change / password reset) - FastAPI dependencies: get_current_user, require_admin diff --git a/backend/requirements.txt b/backend/requirements.txt index 425b1032..a4e1bbab 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,7 +19,6 @@ pydantic[email]==2.12.4 pydantic-settings==2.6.1 # Auth -python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 bcrypt==4.0.1 From a3634af521a19561a07bf940cfd5fca794120cf6 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 10:32:41 -0700 Subject: [PATCH 19/55] tests: new tests for new auth routes --- backend/tests/api/test_auth.py | 285 ++++++++++++++++++++++++++++++++- 1 file changed, 284 insertions(+), 1 deletion(-) diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index a5a913fa..2fa75227 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -37,6 +37,22 @@ def volunteer_no_password(db): return user +@pytest.fixture +def invited_user(db): + """An admin-created user who hasn't completed account-setup yet.""" + user = User( + email="invited@test.com", + first_name="Invited", + last_name="User", + role="user", + status="invited", + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + # --------------------------------------------------------------------------- # POST /auth/login/ # --------------------------------------------------------------------------- @@ -379,4 +395,271 @@ def test_already_verified_rejected(self, client, td_user, db): assert client.post("/auth/send-email-verification/").status_code == 400 def test_unauthenticated_forbidden(self, client): - assert client.post("/auth/send-email-verification/").status_code == 401 \ No newline at end of file + assert client.post("/auth/send-email-verification/").status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /auth/email/request-change/ +# --------------------------------------------------------------------------- + +class TestRequestEmailChange: + def test_request_change_success(self, client, td_user, mock_send_email): + login(client, "td@test.com", "tdpass") + res = client.post("/auth/email/request-change/", json={"new_email": "tdnew@test.com"}) + assert res.status_code == 200 + assert mock_send_email.called + + def test_duplicate_email_rejected(self, client, td_user, other_user): + login(client, "td@test.com", "tdpass") + res = client.post("/auth/email/request-change/", json={"new_email": "other@test.com"}) + assert res.status_code == 409 + + def test_own_current_email_allowed(self, client, td_user): + # Requesting a "change" to the same email isn't a conflict with self + login(client, "td@test.com", "tdpass") + res = client.post("/auth/email/request-change/", json={"new_email": "td@test.com"}) + assert res.status_code == 200 + + def test_rate_limited_on_repeat_request(self, client, td_user): + login(client, "td@test.com", "tdpass") + client.post("/auth/email/request-change/", json={"new_email": "tdnew@test.com"}) + res = client.post("/auth/email/request-change/", json={"new_email": "tdnew2@test.com"}) + assert res.status_code == 429 + + def test_unauthenticated_forbidden(self, client): + assert client.post("/auth/email/request-change/", json={"new_email": "x@test.com"}).status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /auth/email/confirm-change/ +# --------------------------------------------------------------------------- + +class TestConfirmEmailChange: + def test_valid_token_confirms_change(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "email_change", new_email="tdnew@test.com") + res = client.get(f"/auth/email/confirm-change/?token={token}") + assert res.status_code == 200 + db.refresh(td_user) + assert td_user.email == "tdnew@test.com" + assert td_user.email_verified is True + + def test_invalid_token_rejected(self, client): + assert client.get("/auth/email/confirm-change/?token=garbage").status_code == 400 + + def test_token_already_used_rejected(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "email_change", new_email="tdnew@test.com") + assert client.get(f"/auth/email/confirm-change/?token={token}").status_code == 200 + assert client.get(f"/auth/email/confirm-change/?token={token}").status_code == 400 + + def test_wrong_purpose_token_rejected(self, client, td_user, db): + # A signup_verify token shouldn't be usable here + token = create_verification_token(db, td_user.id, "signup_verify") + assert client.get(f"/auth/email/confirm-change/?token={token}").status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /auth/password/change/ +# --------------------------------------------------------------------------- + +class TestChangePassword: + def test_change_password_success(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.post("/auth/password/change/", json={ + "current_password": "tdpass", + "new_password": VALID_PASSWORD, + }) + assert res.status_code == 200 + client.post("/auth/logout/") + assert login(client, "td@test.com", "tdpass").status_code == 401 + assert login(client, "td@test.com", VALID_PASSWORD).status_code == 200 + + def test_wrong_current_password_rejected(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.post("/auth/password/change/", json={ + "current_password": "wrongpass", + "new_password": VALID_PASSWORD, + }) + assert res.status_code == 401 + + def test_new_password_same_as_current_rejected(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.post("/auth/password/change/", json={ + "current_password": "tdpass", + "new_password": "tdpass", + }) + assert res.status_code == 422 + + def test_weak_new_password_rejected(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.post("/auth/password/change/", json={ + "current_password": "tdpass", + "new_password": "weak", + }) + assert res.status_code == 422 + + def test_unauthenticated_forbidden(self, client): + res = client.post("/auth/password/change/", json={ + "current_password": "x", + "new_password": VALID_PASSWORD, + }) + assert res.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /auth/password/reset/request/ +# --------------------------------------------------------------------------- + +class TestRequestPasswordReset: + def test_active_user_with_password_sends_email(self, client, td_user, mock_send_email): + res = client.post("/auth/password/reset/request/", json={"email": "td@test.com"}) + assert res.status_code == 200 + assert mock_send_email.called + + def test_unknown_email_returns_generic_200_no_email_sent(self, client, mock_send_email): + res = client.post("/auth/password/reset/request/", json={"email": "nobody@test.com"}) + assert res.status_code == 200 + assert not mock_send_email.called + + def test_invited_user_excluded_no_email_sent(self, client, invited_user, mock_send_email): + # Pending admin-invited accounts have no password yet — they should + # use account-setup, not password reset. + res = client.post("/auth/password/reset/request/", json={"email": "invited@test.com"}) + assert res.status_code == 200 + assert not mock_send_email.called + + def test_no_password_user_excluded_no_email_sent(self, client, volunteer_no_password, mock_send_email): + res = client.post("/auth/password/reset/request/", json={"email": "vol@test.com"}) + assert res.status_code == 200 + assert not mock_send_email.called + + def test_deactivated_user_excluded_no_email_sent(self, client, inactive_user, mock_send_email): + res = client.post("/auth/password/reset/request/", json={"email": "inactive@test.com"}) + assert res.status_code == 200 + assert not mock_send_email.called + + def test_rate_limit_swallowed_as_generic_200(self, client, td_user): + client.post("/auth/password/reset/request/", json={"email": "td@test.com"}) + res = client.post("/auth/password/reset/request/", json={"email": "td@test.com"}) + assert res.status_code == 200 + + +# --------------------------------------------------------------------------- +# POST /auth/password/reset/confirm/ +# --------------------------------------------------------------------------- + +class TestConfirmPasswordReset: + def test_valid_token_resets_password(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "password_reset") + res = client.post("/auth/password/reset/confirm/", json={ + "token": token, + "new_password": VALID_PASSWORD, + }) + assert res.status_code == 200 + assert login(client, "td@test.com", "tdpass").status_code == 401 + assert login(client, "td@test.com", VALID_PASSWORD).status_code == 200 + + def test_invalid_token_rejected(self, client): + res = client.post("/auth/password/reset/confirm/", json={ + "token": "garbage", + "new_password": VALID_PASSWORD, + }) + assert res.status_code == 400 + + def test_token_already_used_rejected(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "password_reset") + client.post("/auth/password/reset/confirm/", json={"token": token, "new_password": VALID_PASSWORD}) + res = client.post("/auth/password/reset/confirm/", json={"token": token, "new_password": "Another@123"}) + assert res.status_code == 400 + + def test_weak_new_password_rejected(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "password_reset") + res = client.post("/auth/password/reset/confirm/", json={"token": token, "new_password": "weak"}) + assert res.status_code == 422 + + +# --------------------------------------------------------------------------- +# POST /auth/account-setup/confirm/ +# --------------------------------------------------------------------------- + +class TestConfirmAccountSetup: + def test_valid_token_completes_setup(self, client, invited_user, db): + token = create_verification_token(db, invited_user.id, "account_setup") + res = client.post("/auth/account-setup/confirm/", json={ + "token": token, + "password": VALID_PASSWORD, + "phone": VALID_PHONE, + }) + assert res.status_code == 200 + assert "access_token" in res.cookies + db.refresh(invited_user) + assert invited_user.status == "active" + assert invited_user.hashed_password is not None + assert invited_user.phone is not None + + def test_confirm_logs_user_in(self, client, invited_user, db): + token = create_verification_token(db, invited_user.id, "account_setup") + client.post("/auth/account-setup/confirm/", json={ + "token": token, + "password": VALID_PASSWORD, + "phone": VALID_PHONE, + }) + assert client.get("/users/me/").status_code == 200 + + def test_name_correction_is_optional(self, client, invited_user, db): + token = create_verification_token(db, invited_user.id, "account_setup") + res = client.post("/auth/account-setup/confirm/", json={ + "token": token, + "password": VALID_PASSWORD, + "phone": VALID_PHONE, + }) + assert res.json()["first_name"] == "Invited" + + def test_name_correction_applied_when_given(self, client, invited_user, db): + token = create_verification_token(db, invited_user.id, "account_setup") + res = client.post("/auth/account-setup/confirm/", json={ + "token": token, + "password": VALID_PASSWORD, + "phone": VALID_PHONE, + "first_name": "Corrected", + }) + assert res.json()["first_name"] == "Corrected" + + def test_invalid_token_rejected(self, client): + res = client.post("/auth/account-setup/confirm/", json={ + "token": "garbage", + "password": VALID_PASSWORD, + "phone": VALID_PHONE, + }) + assert res.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /admin/auth/account-setup/resend/ +# --------------------------------------------------------------------------- + +class TestResendAccountSetup: + def test_admin_can_resend_for_invited_user(self, client, admin_user, invited_user, mock_send_email): + login(client, "admin@test.com", "adminpass") + res = client.post("/admin/auth/account-setup/resend/", json={"user_id": invited_user.id}) + assert res.status_code == 200 + assert mock_send_email.called + + def test_already_completed_rejected(self, client, admin_user, td_user): + login(client, "admin@test.com", "adminpass") + res = client.post("/admin/auth/account-setup/resend/", json={"user_id": td_user.id}) + assert res.status_code == 400 + + def test_rate_limited_on_repeat_resend(self, client, admin_user, invited_user): + login(client, "admin@test.com", "adminpass") + client.post("/admin/auth/account-setup/resend/", json={"user_id": invited_user.id}) + res = client.post("/admin/auth/account-setup/resend/", json={"user_id": invited_user.id}) + assert res.status_code == 429 + + def test_non_admin_forbidden(self, client, td_user, invited_user): + login(client, "td@test.com", "tdpass") + res = client.post("/admin/auth/account-setup/resend/", json={"user_id": invited_user.id}) + assert res.status_code == 403 + + def test_unauthenticated_forbidden(self, client, invited_user): + res = client.post("/admin/auth/account-setup/resend/", json={"user_id": invited_user.id}) + assert res.status_code == 401 \ No newline at end of file From 4b65558b171cf3a1f0cda89b6218979096a1557e Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 10:36:28 -0700 Subject: [PATCH 20/55] feat(auth): add account settings frontend API client methods --- frontend/lib/api.ts | 52 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 13246996..afc07e40 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -61,7 +61,7 @@ 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, body?: unknown) => request(path, { method: 'DELETE', body }), } // ------------------------------------------------------------------------- @@ -97,6 +97,7 @@ export const canonicalEventsApi = { // Auth / Users // ------------------------------------------------------------------------- export type ROLE = "admin" | "user" +export type USER_STATUS = "active" | "invited" | "deactivated" | "locked" export type STUDENT_STATUS = "Undergraduate" | "Graduate" | "Non-Student" export type SHIRT_SIZE = "XS" | "S" | "M" | "L" | "XL" | "XXL" @@ -132,7 +133,7 @@ export interface UserSlim { email_verified: boolean role: ROLE - is_active: boolean + status: USER_STATUS created_at: string updated_at: string @@ -205,11 +206,47 @@ export const authApi = { register: (body: AuthRegister) => api.post('/auth/register/', body), verifyEmail: (token: string) => api.get(`/auth/verify-email/?token=${token}`), sendEmailVerification: () => api.post('/auth/send-email-verification/', {}), + + requestEmailChange: (newEmail: string) => + api.post('/auth/email/request-change/', { new_email: newEmail }), + confirmEmailChange: (token: string) => + api.get(`/auth/email/confirm-change/?token=${token}`), + + changePassword: (currentPassword: string, newPassword: string) => + api.post('/auth/password/change/', { current_password: currentPassword, new_password: newPassword }), + requestPasswordReset: (email: string) => + api.post('/auth/password/reset/request/', { email }), + confirmPasswordReset: (token: string, newPassword: string) => + api.post('/auth/password/reset/confirm/', { token, new_password: newPassword }), + + confirmAccountSetup: ( + token: string, + password: string, + phone: string, + firstName?: string, + lastName?: string, + ) => + api.post('/auth/account-setup/confirm/', { + token, + password, + phone, + first_name: firstName, + last_name: lastName, + }), } // ------------------------------------------------------------------------- // Users // ------------------------------------------------------------------------- +export interface UserSession { + id: number + user_agent: string | null + ip_address: string | null + created_at: string + last_active_at: string | null + is_current: boolean +} + export const usersApi = { // GET /users/me/ (default) — matches UserMeSlimResponse me: () => api.get('/users/me/'), @@ -244,14 +281,21 @@ export const usersApi = { getForTournament: (tournamentId: number, userId: number) => api.get(`/tournaments/${tournamentId}/users/${userId}/`), + + listSessions: () => api.get('/users/me/sessions/'), + logoutOtherSessions: () => api.post('/users/me/sessions/logout-others/', {}), + deactivateAccount: (currentPassword: string) => + api.post('/users/me/deactivate/', { password: currentPassword }), + deleteAccount: (currentPassword: string) => + api.delete('/users/me/', { password: currentPassword }), } // ------------------------------------------------------------------------- // Admin — Users // ------------------------------------------------------------------------- interface AdminUserUpdate { - role?: ROLE - is_active?: boolean + role?: ROLE + status?: USER_STATUS } export const adminUsersApi = { From 710b545c1c62482e896c62cccd6c811960daa8b1 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 10:57:30 -0700 Subject: [PATCH 21/55] feat(settings): add settings layout and account page --- frontend/app/profile/[id]/edit/page.tsx | 43 +--- frontend/app/settings/account/page.tsx | 239 +++++++++++++++++++ frontend/app/settings/layout.tsx | 16 ++ frontend/components/settings/SettingsNav.tsx | 63 +++++ frontend/components/settings/SettingsRow.tsx | 47 ++++ frontend/components/ui/FloatingSaveBar.tsx | 42 ++++ frontend/components/ui/Icons.tsx | 9 + 7 files changed, 417 insertions(+), 42 deletions(-) create mode 100644 frontend/app/settings/account/page.tsx create mode 100644 frontend/app/settings/layout.tsx create mode 100644 frontend/components/settings/SettingsNav.tsx create mode 100644 frontend/components/settings/SettingsRow.tsx create mode 100644 frontend/components/ui/FloatingSaveBar.tsx diff --git a/frontend/app/profile/[id]/edit/page.tsx b/frontend/app/profile/[id]/edit/page.tsx index 43bef927..90886167 100644 --- a/frontend/app/profile/[id]/edit/page.tsx +++ b/frontend/app/profile/[id]/edit/page.tsx @@ -7,8 +7,8 @@ import { usersApi, canonicalEventsApi, CanonicalEvent, STUDENT_STATUS, SHIRT_SIZE, UserMeFull, ApiError, } from "@/lib/api"; -import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; +import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; import { Topbar } from "@/components/layout/Topbar"; import { ProfileCard } from "@/components/profile/ProfileCard"; import { ProfileHeader } from "@/components/profile/sections/ProfileHeader"; @@ -27,47 +27,6 @@ import { -interface FloatingSaveBarProps { - visible: boolean; - saving?: boolean; - error?: string; - onSave: () => void; - onCancel: () => void; -} - -function FloatingSaveBar({ visible, saving, error, onSave, onCancel }: FloatingSaveBarProps) { - return ( -
-
- - You have unsaved changes - - {error && ( -
- {error} -
- )} -
-
- - -
-
- ); -} - - - interface ProfileDraft { pronouns?: string student_status?: STUDENT_STATUS diff --git a/frontend/app/settings/account/page.tsx b/frontend/app/settings/account/page.tsx new file mode 100644 index 00000000..f6adb3e3 --- /dev/null +++ b/frontend/app/settings/account/page.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { usersApi, authApi, UserMeFull, ApiError } from "@/lib/api"; +import { validatePhone, validateDateOfBirth, formatPhone } from "@/lib/auth"; +import { useFormattedInputChange } from "@/lib/useFormattedInput"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { Banner } from "@/components/ui/Banner"; +import { Spinner } from "@/components/ui/Spinner"; +import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; +import { SettingsSection, SettingsRow } from "@/components/settings/SettingsRow"; + +interface ProfileDraft { + first_name: string; + last_name: string; + phone: string; + date_of_birth: string; +} + +function toDraft(user: UserMeFull): ProfileDraft { + return { + first_name: user.first_name, + last_name: user.last_name, + phone: user.phone ?? "", + date_of_birth: user.date_of_birth ?? "", + }; +} + +export default function AccountSettingsPage() { + const { user: currentUser, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [original, setOriginal] = useState(null); + const [loadError, setLoadError] = useState(null); + const [draft, setDraft] = useState(null); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(undefined); + + // ── Email change — separate flow from the save bar ───────────────────── + const [newEmail, setNewEmail] = useState(""); + const [emailRequestError, setEmailRequestError] = useState(undefined); + const [emailRequestSent, setEmailRequestSent] = useState(null); + const [emailRequesting, setEmailRequesting] = useState(false); + + useEffect(() => { + if (authLoading) return; + if (!currentUser) { + router.replace("/"); + return; + } + + usersApi.meFull() + .then((user) => { + setOriginal(user); + setDraft(toDraft(user)); + }) + .catch(() => setLoadError("Failed to load account settings.")); + }, [authLoading, currentUser, router]); + + const isDirty = useMemo(() => { + if (!original || !draft) return false; + return JSON.stringify(draft) !== JSON.stringify(toDraft(original)); + }, [draft, original]); + + const phoneChange = useFormattedInputChange( + draft?.phone ?? "", + (next) => setDraft((d) => (d ? { ...d, phone: next } : d)), + formatPhone, + ); + + function handleCancel() { + if (!original) return; + setDraft(toDraft(original)); + setErrors({}); + setSaveError(undefined); + } + + async function handleSave() { + if (!original || !draft) return; + setSaving(true); + setSaveError(undefined); + setErrors({}); + + const phoneErr = validatePhone(draft.phone); + if (phoneErr) { + setErrors((e) => ({ ...e, phone: phoneErr })); + setSaving(false); + return; + } + + const dobErr = draft.date_of_birth ? validateDateOfBirth(draft.date_of_birth) : null; + if (dobErr) { + setErrors((e) => ({ ...e, date_of_birth: dobErr })); + setSaving(false); + return; + } + + try { + const updated = await usersApi.updateMe({ + first_name: draft.first_name, + last_name: draft.last_name, + phone: draft.phone, + date_of_birth: draft.date_of_birth || null, + }); + setOriginal(updated); + setDraft(toDraft(updated)); + } catch (error: unknown) { + setSaveError(error instanceof ApiError ? error.message : "Something went wrong. Try again."); + } finally { + setSaving(false); + } + } + + async function handleRequestEmailChange() { + setEmailRequestError(undefined); + setEmailRequesting(true); + try { + await authApi.requestEmailChange(newEmail); + setEmailRequestSent(newEmail); + setNewEmail(""); + } catch (error: unknown) { + setEmailRequestError(error instanceof ApiError ? error.message : "Failed to send confirmation email."); + } finally { + setEmailRequesting(false); + } + } + + if (authLoading || (!original && !loadError)) { + return ( +
+ +
+ ); + } + + if (loadError || !original || !draft) { + return ( +

+ {loadError ?? "Account not found."} +

+ ); + } + + return ( +
+

+ Account +

+ + + + setDraft((d) => (d ? { ...d, first_name: e.target.value } : d))} + /> + + + setDraft((d) => (d ? { ...d, last_name: e.target.value } : d))} + /> + + + { + phoneChange(e); + setErrors((er) => ({ ...er, phone: undefined })); + }} + error={errors.phone} + /> + + + { + setDraft((d) => (d ? { ...d, date_of_birth: e.target.value } : d)); + setErrors((er) => ({ ...er, date_of_birth: undefined })); + }} + error={errors.date_of_birth} + /> + + + + + + + + + {emailRequestSent ? ( + + ) : ( +
+ setNewEmail(e.target.value)} + error={emailRequestError} + placeholder="new@example.com" + /> + +
+ )} +
+
+ + +
+ ); +} diff --git a/frontend/app/settings/layout.tsx b/frontend/app/settings/layout.tsx new file mode 100644 index 00000000..0df0bc71 --- /dev/null +++ b/frontend/app/settings/layout.tsx @@ -0,0 +1,16 @@ +import { Topbar } from "@/components/layout/Topbar"; +import { SettingsNav } from "@/components/settings/SettingsNav"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
+ {children} +
+
+
+ ); +} diff --git a/frontend/components/settings/SettingsNav.tsx b/frontend/components/settings/SettingsNav.tsx new file mode 100644 index 00000000..f20fb3b7 --- /dev/null +++ b/frontend/components/settings/SettingsNav.tsx @@ -0,0 +1,63 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { IconUser, IconShield } from "@/components/ui/Icons"; + +const NAV_ITEMS = [ + { href: "/settings/account", icon: , label: "Account" }, + { href: "/settings/security", icon: , label: "Security" }, +]; + +export function SettingsNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/frontend/components/settings/SettingsRow.tsx b/frontend/components/settings/SettingsRow.tsx new file mode 100644 index 00000000..e902a008 --- /dev/null +++ b/frontend/components/settings/SettingsRow.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from "react"; + +interface SettingsRowProps { + label: string; + helper?: string; + children: ReactNode; + last?: boolean; +} + +export function SettingsRow({ label, helper, children, last = false }: SettingsRowProps) { + return ( +
+
+
+ {label} +
+ {helper && ( +
+ {helper} +
+ )} +
+
+ {children} +
+
+ ); +} + +export function SettingsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+ {title} +
+
{children}
+
+ ); +} diff --git a/frontend/components/ui/FloatingSaveBar.tsx b/frontend/components/ui/FloatingSaveBar.tsx new file mode 100644 index 00000000..dbb06fae --- /dev/null +++ b/frontend/components/ui/FloatingSaveBar.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Button } from "@/components/ui/Button" + +interface FloatingSaveBarProps { + visible: boolean; + saving?: boolean; + error?: string; + onSave: () => void; + onCancel: () => void; +} + +export function FloatingSaveBar({ visible, saving, error, onSave, onCancel }: FloatingSaveBarProps) { + return ( +
+
+ + You have unsaved changes + + {error && ( +
+ {error} +
+ )} +
+
+ + +
+
+ ); +} diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index ef7eb17d..1e67a0db 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -75,6 +75,15 @@ export function IconSettings({ size = 18, ...props }: IconProps) { ); } +export function IconShield({ size = 18, ...props }: IconProps) { + return ( + + + + + ); +} + // ─── Actions ────────────────────────────────────────────────────────────────── export function IconPlus({ size = 16, ...props }: IconProps) { From 0941221de025caab5c7d6e6688dd2df5228714dd Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 11:19:40 -0700 Subject: [PATCH 22/55] refactor(settings): move email change into its own modal with resend cooldown --- frontend/app/settings/account/page.tsx | 74 +++--------- .../components/settings/ChangeEmailModal.tsx | 108 ++++++++++++++++++ 2 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 frontend/components/settings/ChangeEmailModal.tsx diff --git a/frontend/app/settings/account/page.tsx b/frontend/app/settings/account/page.tsx index f6adb3e3..8a74365c 100644 --- a/frontend/app/settings/account/page.tsx +++ b/frontend/app/settings/account/page.tsx @@ -3,15 +3,15 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/useAuth"; -import { usersApi, authApi, UserMeFull, ApiError } from "@/lib/api"; +import { usersApi, UserMeFull, ApiError } from "@/lib/api"; import { validatePhone, validateDateOfBirth, formatPhone } from "@/lib/auth"; import { useFormattedInputChange } from "@/lib/useFormattedInput"; import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; -import { Banner } from "@/components/ui/Banner"; import { Spinner } from "@/components/ui/Spinner"; import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; -import { SettingsSection, SettingsRow } from "@/components/settings/SettingsRow"; +import { SettingsRow } from "@/components/settings/SettingsRow"; +import { ChangeEmailModal } from "@/components/settings/ChangeEmailModal"; interface ProfileDraft { first_name: string; @@ -40,11 +40,7 @@ export default function AccountSettingsPage() { const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(undefined); - // ── Email change — separate flow from the save bar ───────────────────── - const [newEmail, setNewEmail] = useState(""); - const [emailRequestError, setEmailRequestError] = useState(undefined); - const [emailRequestSent, setEmailRequestSent] = useState(null); - const [emailRequesting, setEmailRequesting] = useState(false); + const [showEmailModal, setShowEmailModal] = useState(false); useEffect(() => { if (authLoading) return; @@ -115,20 +111,6 @@ export default function AccountSettingsPage() { } } - async function handleRequestEmailChange() { - setEmailRequestError(undefined); - setEmailRequesting(true); - try { - await authApi.requestEmailChange(newEmail); - setEmailRequestSent(newEmail); - setNewEmail(""); - } catch (error: unknown) { - setEmailRequestError(error instanceof ApiError ? error.message : "Failed to send confirmation email."); - } finally { - setEmailRequesting(false); - } - } - if (authLoading || (!original && !loadError)) { return (
@@ -151,7 +133,7 @@ export default function AccountSettingsPage() { Account - +
setDraft((d) => (d ? { ...d, last_name: e.target.value } : d))} /> + +
+ + {original.email} + + +
+
- +
- - - - - - {emailRequestSent ? ( - - ) : ( -
- setNewEmail(e.target.value)} - error={emailRequestError} - placeholder="new@example.com" - /> - -
- )} -
-
+ {showEmailModal && ( + setShowEmailModal(false)} /> + )} void; +} + +export function ChangeEmailModal({ currentEmail, onClose }: ChangeEmailModalProps) { + const [newEmail, setNewEmail] = useState(""); + const [error, setError] = useState(undefined); + const [sending, setSending] = useState(false); + const [sentAt, setSentAt] = useState(null); + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + if (sentAt === null) return; + + function tick() { + const remaining = COOLDOWN_SECONDS - Math.floor((Date.now() - sentAt!) / 1000); + setCooldown(Math.max(0, remaining)); + } + + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [sentAt]); + + async function handleSend() { + const err = validateEmail(newEmail); + if (err) { + setError(err); + return; + } + if (newEmail.toLowerCase() === currentEmail.toLowerCase()) { + setError("That's already your current email."); + return; + } + + setError(undefined); + setSending(true); + try { + await authApi.requestEmailChange(newEmail); + setSentAt(Date.now()); + } catch (e: unknown) { + setError(e instanceof ApiError ? e.message : "Failed to send confirmation email."); + } finally { + setSending(false); + } + } + + const onCooldown = sentAt !== null && cooldown > 0; + + return ( + +
+

+ Currently {currentEmail}. We'll send a confirmation link to the new address — + your email won't change until you click it. +

+ + { + setNewEmail(e.target.value); + setError(undefined); + }} + error={error} + placeholder="new@example.com" + autoFocus + /> + +
+ +
+ + {sentAt !== null && ( +

+ {onCooldown + ? `Verification sent to ${newEmail}. Didn't receive it? Try again in ${cooldown}s.` + : `Verification sent to ${newEmail}. Didn't receive it? You can resend now.`} +

+ )} +
+
+ ); +} From e0fdcd31c3141aa0980b9b43270787d3a547df79 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 11:26:05 -0700 Subject: [PATCH 23/55] fix(auth): redirect and clear cookie on invalid session token --- frontend/lib/useAuth.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/lib/useAuth.tsx b/frontend/lib/useAuth.tsx index 86e68eb8..061280d6 100644 --- a/frontend/lib/useAuth.tsx +++ b/frontend/lib/useAuth.tsx @@ -30,8 +30,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { usersApi.me() .then(setUser) .catch((err: unknown) => { + setUser(null) if (err instanceof ApiError && err.status === 401) { - setUser(null) + // Cookie is present (middleware already checked that) but invalid — + // clear it server-side and bounce back to sign-in. + authApi.logout().finally(() => { + window.location.href = '/' + }) } }) .finally(() => setLoading(false)) From 8cdced85e31cf19856e599938cdad8728fcab217 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 11:31:08 -0700 Subject: [PATCH 24/55] fix(users): remove email field from PATCH /users/me/ to close verification bypass --- backend/app/api/routes/users.py | 7 +++---- backend/app/schemas/user.py | 3 +-- backend/tests/api/test_users.py | 32 +++++++------------------------- 3 files changed, 11 insertions(+), 31 deletions(-) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index d5b83cf9..f7794dc7 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -8,7 +8,7 @@ get_current_user, require_admin, revoke_all_sessions, verify_password, clear_auth_cookie, get_current_session, revoke_all_other_sessions, ) -from app.core.users import check_if_email_exists, find_user_by_id +from app.core.users import find_user_by_id from app.core.profile_status import compute_missing_profile_fields, is_profile_complete from app.db.session import get_db from app.models.models import User, UserCompetitionExperience, UserVolunteerExperience, Event, UserSession @@ -153,11 +153,10 @@ def update_user_me( """ Update the current user's own profile. Omitted fields are left unchanged. Explicit null clears a field. - Email uniqueness is checked before applying changes. + Email changes must go through the verify-before-apply flow + (POST /auth/email/request-change/ + GET /auth/email/confirm-change/). """ for field, value in body.model_dump(exclude_unset=True).items(): - if field == "email": - check_if_email_exists(db, body.email, exclude_user_id=user.id) setattr(user, field, value) db.commit() db.refresh(user) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 6477c06f..0feceb9d 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -16,7 +16,6 @@ class UserUpdate(BaseModel): """Partial update — all fields optional.""" first_name: Optional[str] = None last_name: Optional[str] = None - email: Optional[EmailStr] = None phone: Optional[str] = None date_of_birth: Optional[date] = None pronouns: Optional[str] = None @@ -35,7 +34,7 @@ class UserUpdate(BaseModel): shirt_size: Optional[str] = None dietary_restriction: Optional[str] = None - @field_validator("first_name", "last_name", "email", "phone") + @field_validator("first_name", "last_name", "phone") @classmethod def reject_null(cls, v): if v is None: diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index 280919e5..22166792 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -388,26 +388,16 @@ def test_unset_fields_unchanged(self, client, td_user): client.patch("/users/me/", json={"first_name": "Updated"}) assert client.get("/users/me/").json()["last_name"] == "User" - def test_can_update_email(self, client, td_user): + def test_email_field_ignored(self, client, td_user): + # Email changes must go through the verify-before-apply flow + # (POST /auth/email/request-change/ + GET /auth/email/confirm-change/), + # not this generic partial-update route. `email` isn't a declared + # field on UserUpdate, so pydantic silently drops it. 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_duplicate_email_case_insensitive_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_same_email_unchanged_not_rejected(self, client, td_user): - # Re-submitting your own current email should not conflict with yourself. - login(client, "td@test.com", "tdpass") - res = client.patch("/users/me/", json={"email": "td@test.com", "first_name": "Still TD"}) + res = client.patch("/users/me/", json={"email": "newemail@test.com", "first_name": "Updated"}) assert res.status_code == 200 assert res.json()["email"] == "td@test.com" + assert res.json()["first_name"] == "Updated" def test_null_clears_optional_field(self, client, td_user): login(client, "td@test.com", "tdpass") @@ -424,18 +414,10 @@ def test_null_last_name_rejected(self, client, td_user): login(client, "td@test.com", "tdpass") assert client.patch("/users/me/", json={"last_name": None}).status_code == 422 - def test_null_email_rejected(self, client, td_user): - login(client, "td@test.com", "tdpass") - assert client.patch("/users/me/", json={"email": None}).status_code == 422 - def test_null_phone_rejected(self, client, td_user): login(client, "td@test.com", "tdpass") assert client.patch("/users/me/", json={"phone": None}).status_code == 422 - 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") From 3839a05b84afc13deba66d710d9a112c65b7335b Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 11:33:38 -0700 Subject: [PATCH 25/55] feat(nav): add settings link to user avatar dropdown --- frontend/components/ui/UserAvatar.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/components/ui/UserAvatar.tsx b/frontend/components/ui/UserAvatar.tsx index 047bfbf7..295702ce 100644 --- a/frontend/components/ui/UserAvatar.tsx +++ b/frontend/components/ui/UserAvatar.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { useAuth } from "@/lib/useAuth"; -import { IconLogout, IconUser } from "@/components/ui/Icons"; +import { IconLogout, IconUser, IconSettings } from "@/components/ui/Icons"; import { AvatarCircle } from "@/components/ui/AvatarCircle"; import Link from "next/link"; @@ -71,6 +71,22 @@ export function UserAvatar() { Profile + setOpen(false)} + style={{ + display: "flex", alignItems: "center", gap: "8px", + width: "100%", padding: "11px 16px", + fontFamily: "var(--font-sans)", fontSize: "13px", fontWeight: 500, + color: "var(--color-text-primary)", textDecoration: "none", + borderBottom: "1px solid var(--color-border)", + }} + onMouseEnter={(e) => { e.currentTarget.style.background = "var(--color-bg)"; }} + onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }} + > + + Settings + + +
+ ); +} diff --git a/frontend/components/settings/PasswordChecklist.tsx b/frontend/components/settings/PasswordChecklist.tsx new file mode 100644 index 00000000..5cff7c7b --- /dev/null +++ b/frontend/components/settings/PasswordChecklist.tsx @@ -0,0 +1,27 @@ +import { PasswordChecks } from "@/lib/auth"; +import { IconCheckCircle, IconXCircle } from "@/components/ui/Icons"; + +const ITEMS: { key: keyof PasswordChecks; label: string }[] = [ + { 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" }, +]; + +export function PasswordChecklist({ checks }: { checks: PasswordChecks }) { + return ( +
+ {ITEMS.map(({ key, label }) => ( +
+ {checks[key] + ? + : + } + {label} +
+ ))} +
+ ); +} From dc1a2c241291f41bb0abaf2511b036caf11dbd8d Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 12:45:16 -0700 Subject: [PATCH 30/55] feat(auth): add password reset and email-change confirmation pages, centralize auth card layout --- backend/app/services/email_service.py | 2 +- .../app/(auth)/confirm-email-change/page.tsx | 74 +++++++++++ frontend/app/(auth)/forgot-password/page.tsx | 66 ++++++++++ frontend/app/(auth)/layout.tsx | 19 ++- frontend/app/(auth)/reset-password/page.tsx | 116 ++++++++++++++++++ frontend/app/(auth)/sign-in/page.tsx | 14 +-- frontend/app/(auth)/sign-up/page.tsx | 27 +--- frontend/app/(auth)/verify-email/page.tsx | 22 +--- frontend/proxy.ts | 12 +- 9 files changed, 288 insertions(+), 64 deletions(-) create mode 100644 frontend/app/(auth)/confirm-email-change/page.tsx create mode 100644 frontend/app/(auth)/forgot-password/page.tsx create mode 100644 frontend/app/(auth)/reset-password/page.tsx diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 644647c9..f0b13db9 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -179,7 +179,7 @@ async def send_signup_verification_email(db: Session, to: str, user_id: int) -> # --------------------------------------------------------------------------- async def send_email_change_email(to_new_email: str, token: str) -> None: - url = _cta_url("/settings/account/confirm-email", token) + url = _cta_url("/confirm-email-change", token) html = _render_email_html( heading="Confirm your new email", body_lines=[ diff --git a/frontend/app/(auth)/confirm-email-change/page.tsx b/frontend/app/(auth)/confirm-email-change/page.tsx new file mode 100644 index 00000000..aaedb0a8 --- /dev/null +++ b/frontend/app/(auth)/confirm-email-change/page.tsx @@ -0,0 +1,74 @@ +'use client' + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { ApiError, authApi } from "@/lib/api" +import { useAuth } from "@/lib/useAuth" +import { Button } from "@/components/ui/Button" +import { Spinner } from "@/components/ui/Spinner" +import { IconCheckCircle, IconXCircle } from "@/components/ui/Icons" + +export default function ConfirmEmailChangePage() { + return ( + }> + + + ) +} + +function ConfirmEmailChangeContent() { + const [state, setState] = useState<'loading' | 'success' | 'error'>('loading') + const [error, setError] = useState(undefined) + const { user } = useAuth() + const router = useRouter() + + const searchParams = useSearchParams() + const token = searchParams.get("token") + + useEffect(() => { + authApi.confirmEmailChange(token ?? '').then(() => setState('success')).catch(err => { + setError(err instanceof ApiError ? err.message : "Something went wrong") + setState('error') + }) + }, []) + + return ( + <> +
+

+ Confirm Email Change +

+
+ + {state === 'loading' && ( + + )} + + {state === 'success' && ( +
+ + Your email has been updated. + + {user ? ( + + ) : ( + + You can close this window. + + )} +
+ )} + + {state === 'error' && ( +
+ + Error: {error} + + {user && ( + + )} +
+ )} + + ) +} diff --git a/frontend/app/(auth)/forgot-password/page.tsx b/frontend/app/(auth)/forgot-password/page.tsx new file mode 100644 index 00000000..6c1375b3 --- /dev/null +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from "react" +import { ApiError, authApi } from "@/lib/api" +import { validateEmail } from "@/lib/auth" +import { Input } from "@/components/ui/Input" +import { Button } from "@/components/ui/Button" + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState('') + const [error, setError] = useState(undefined) + const [loading, setLoading] = useState(false) + const [sent, setSent] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + const err = validateEmail(email) + if (err) { + setError(err) + return + } + + setError(undefined) + setLoading(true) + try { + await authApi.requestPasswordReset(email) + setSent(true) + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Something went wrong") + } finally { + setLoading(false) + } + } + + return ( + <> +
+

+ Reset your password +

+
+ + {sent ? ( +

+ If an account exists for that email, a reset link has been sent. +

+ ) : ( +
+ { setEmail(e.target.value); setError(undefined) }} + autoComplete="email" + error={error} + fullWidth + /> + +
+ )} + + ) +} diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx index 7f70bb0b..065a80f8 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -3,7 +3,22 @@ import { ReactNode } from 'react' export default function AuthLayout({ children }: { children: ReactNode }) { return (
- {children} +
+
+

NEXUS

+
+ {children} +
) -} \ No newline at end of file +} diff --git a/frontend/app/(auth)/reset-password/page.tsx b/frontend/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..8ef39c27 --- /dev/null +++ b/frontend/app/(auth)/reset-password/page.tsx @@ -0,0 +1,116 @@ +'use client' + +import { Suspense, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { ApiError, authApi } from "@/lib/api" +import { checkPassword, validatePassword, PasswordChecks } from "@/lib/auth" +import { Input } from "@/components/ui/Input" +import { Button } from "@/components/ui/Button" +import { Spinner } from "@/components/ui/Spinner" +import { IconCheckCircle, IconXCircle } from "@/components/ui/Icons" +import { PasswordChecklist } from "@/components/settings/PasswordChecklist" + +const EMPTY_CHECKS: PasswordChecks = { + length: false, upper: false, lower: false, number: false, symbol: false, confirm: false, +} + +export default function ResetPasswordPage() { + return ( + }> + + + ) +} + +function ResetPasswordContent() { + const searchParams = useSearchParams() + const token = searchParams.get("token") ?? '' + const router = useRouter() + + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [checks, setChecks] = useState(EMPTY_CHECKS) + const [error, setError] = useState(undefined) + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + const passwordErr = validatePassword(newPassword) + if (passwordErr) { + setError(passwordErr) + return + } + if (newPassword !== confirmPassword) { + setError("Passwords don't match.") + return + } + + setError(undefined) + setLoading(true) + try { + await authApi.confirmPasswordReset(token, newPassword) + setSuccess(true) + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Something went wrong") + } finally { + setLoading(false) + } + } + + return ( + <> +
+

+ Set a new password +

+
+ + {success ? ( +
+ + Password successfully reset. + + +
+ ) : ( +
+ { + setNewPassword(e.target.value) + setChecks(checkPassword(e.target.value, confirmPassword)) + setError(undefined) + }} + autoComplete="new-password" + fullWidth + /> + { + setConfirmPassword(e.target.value) + setChecks(checkPassword(newPassword, e.target.value)) + setError(undefined) + }} + autoComplete="new-password" + fullWidth + /> + + {error && ( + + {error} + + )} + + + )} + + ) +} diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx index 86012eeb..fedbacfa 100644 --- a/frontend/app/(auth)/sign-in/page.tsx +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -47,7 +47,7 @@ export default function SignInPage() { } return ( -
+
- -
-

NEXUS

-
- +

-

+ ) } diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index 0e32a916..af58d3cc 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -11,7 +11,6 @@ import { IconArrowLeft, IconCheckCircle, IconXCircle } from "@/components/ui/Ico import { Input } from "@/components/ui/Input" import { Button } from "@/components/ui/Button" import { Modal } from "@/components/ui/Modal" -import { ProfileCard } from "@/components/profile/ProfileCard" import { ProfileQuestion } from "@/components/profile/ProfileQuestion" import { PronounsField, StudentStatusField, @@ -287,7 +286,7 @@ export default function SignUpPage() { return ( <> {state === STATE.ACCOUNT && ( -
+
-
-

NEXUS

-
-

-

+ )} {state >= STATE.DATE_OF_BIRTH && ( -
+
{showVerifyModal && } - -
-

NEXUS

-
-

- -

+ )} ) diff --git a/frontend/app/(auth)/verify-email/page.tsx b/frontend/app/(auth)/verify-email/page.tsx index 59d9ca82..32294d0c 100644 --- a/frontend/app/(auth)/verify-email/page.tsx +++ b/frontend/app/(auth)/verify-email/page.tsx @@ -48,25 +48,7 @@ function VerifyEmailContent() { }, []) return ( -
-
-

NEXUS

-
- + <>

)} -

+ ) } \ No newline at end of file diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 4d5338fb..ca0f402d 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -28,13 +28,11 @@ export function proxy(request: NextRequest) { return NextResponse.redirect(url) } - if (pathname === '/verify-email') { - const verifyToken = request.nextUrl.searchParams.get('token') - if (!verifyToken) { - const url = request.nextUrl.clone() - url.pathname = token ? '/dashboard' : '/' - return NextResponse.redirect(url) - } + const TOKEN_REQUIRED_ROUTES = ['/verify-email', '/reset-password', '/confirm-email-change'] + if (TOKEN_REQUIRED_ROUTES.includes(pathname) && !request.nextUrl.searchParams.get('token')) { + const url = request.nextUrl.clone() + url.pathname = token ? '/dashboard' : '/' + return NextResponse.redirect(url) } return NextResponse.next() From 7160ff7f35b3ed4de5121637894d9238821603c9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 12:49:08 -0700 Subject: [PATCH 31/55] fix(auth): add vertical padding to auth layout container --- frontend/app/(auth)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx index 065a80f8..d04dbabb 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react' export default function AuthLayout({ children }: { children: ReactNode }) { return ( -
+
Date: Tue, 28 Jul 2026 12:56:25 -0700 Subject: [PATCH 32/55] fix(auth): force full reload on sign-in so auth context refetches user --- frontend/app/(auth)/sign-in/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx index fedbacfa..589e6e2f 100644 --- a/frontend/app/(auth)/sign-in/page.tsx +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -34,7 +34,7 @@ export default function SignInPage() { try { await authApi.login(email, password) - router.push('/dashboard') + window.location.href = '/dashboard' } catch (error: unknown) { if (error instanceof ApiError) { setErrors({ form: error.message }) From 7173825316056b0479bed0899b86806999588033 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 12:58:56 -0700 Subject: [PATCH 33/55] feat(settings): add session list and log-out-everywhere to security page --- frontend/app/settings/security/page.tsx | 135 ++++++++++--------- frontend/components/settings/SessionList.tsx | 107 +++++++++++++++ frontend/lib/sessionFormat.ts | 35 +++++ 3 files changed, 213 insertions(+), 64 deletions(-) create mode 100644 frontend/components/settings/SessionList.tsx create mode 100644 frontend/lib/sessionFormat.ts diff --git a/frontend/app/settings/security/page.tsx b/frontend/app/settings/security/page.tsx index f009b86e..5467f46f 100644 --- a/frontend/app/settings/security/page.tsx +++ b/frontend/app/settings/security/page.tsx @@ -8,8 +8,9 @@ import { checkPassword, validatePassword, PasswordChecks } from "@/lib/auth"; import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; import { Banner } from "@/components/ui/Banner"; -import { SettingsRow } from "@/components/settings/SettingsRow"; +import { SettingsRow, SettingsSection } from "@/components/settings/SettingsRow"; import { PasswordChecklist } from "@/components/settings/PasswordChecklist"; +import { SessionList } from "@/components/settings/SessionList"; const EMPTY_CHECKS: PasswordChecks = { length: false, upper: false, lower: false, number: false, symbol: false, confirm: false, @@ -78,71 +79,77 @@ export default function SecuritySettingsPage() { Security -
- - { - setCurrentPassword(e.target.value); - setErrors((er) => ({ ...er, current_password: undefined })); - }} - autoComplete="current-password" - error={errors.current_password} - /> - - - { - setNewPassword(e.target.value); - setChecks(checkPassword(e.target.value, confirmPassword)); - setErrors((er) => ({ ...er, new_password: undefined })); - }} - autoComplete="new-password" - error={errors.new_password} - /> - - - { - setConfirmPassword(e.target.value); - setChecks(checkPassword(newPassword, e.target.value)); - setErrors((er) => ({ ...er, confirm_password: undefined })); - }} - autoComplete="new-password" - error={errors.confirm_password} - /> - - -
- -
- - {success && ( -
- -
- )} - {errors.form && ( -
- + + + + { + setCurrentPassword(e.target.value); + setErrors((er) => ({ ...er, current_password: undefined })); + }} + autoComplete="current-password" + error={errors.current_password} + /> + + + { + setNewPassword(e.target.value); + setChecks(checkPassword(e.target.value, confirmPassword)); + setErrors((er) => ({ ...er, new_password: undefined })); + }} + autoComplete="new-password" + error={errors.new_password} + /> + + + { + setConfirmPassword(e.target.value); + setChecks(checkPassword(newPassword, e.target.value)); + setErrors((er) => ({ ...er, confirm_password: undefined })); + }} + autoComplete="new-password" + error={errors.confirm_password} + /> + + +
+
- )} - - + {success && ( +
+ +
+ )} + {errors.form && ( +
+ +
+ )} + + + +
+ + + +
); } diff --git a/frontend/components/settings/SessionList.tsx b/frontend/components/settings/SessionList.tsx new file mode 100644 index 00000000..bc504878 --- /dev/null +++ b/frontend/components/settings/SessionList.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usersApi, UserSession, ApiError } from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Banner } from "@/components/ui/Banner"; +import { Spinner } from "@/components/ui/Spinner"; +import { parseUserAgent, formatRelativeTime } from "@/lib/sessionFormat"; + +export function SessionList() { + const [sessions, setSessions] = useState(null); + const [loadError, setLoadError] = useState(undefined); + const [loggingOut, setLoggingOut] = useState(false); + const [logoutError, setLogoutError] = useState(undefined); + const [logoutSuccess, setLogoutSuccess] = useState(false); + + function load() { + usersApi.listSessions().then(setSessions).catch(() => setLoadError("Failed to load sessions.")); + } + + useEffect(() => { load(); }, []); + + async function handleLogoutOthers() { + setLoggingOut(true); + setLogoutError(undefined); + setLogoutSuccess(false); + try { + await usersApi.logoutOtherSessions(); + setLogoutSuccess(true); + load(); + } catch (error: unknown) { + setLogoutError(error instanceof ApiError ? error.message : "Something went wrong."); + } finally { + setLoggingOut(false); + } + } + + if (loadError) { + return ( +

+ {loadError} +

+ ); + } + + if (!sessions) { + return ( +
+ +
+ ); + } + + const otherCount = sessions.filter((s) => !s.is_current).length; + + return ( +
+ {sessions.map((s, i) => ( +
+
+ + {parseUserAgent(s.user_agent)} + + {s.is_current && ( + + This device + + )} +
+
+ {s.ip_address ?? "Unknown IP"} · Active {formatRelativeTime(s.last_active_at)} +
+
+ ))} + + {otherCount > 0 && ( +
+ +

+ Signs out every other session — this device stays logged in. +

+
+ )} + + {logoutSuccess && ( +
+ +
+ )} + {logoutError && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/lib/sessionFormat.ts b/frontend/lib/sessionFormat.ts new file mode 100644 index 00000000..73a8ff5c --- /dev/null +++ b/frontend/lib/sessionFormat.ts @@ -0,0 +1,35 @@ +export function parseUserAgent(ua: string | null): string { + if (!ua) return "Unknown device" + + let os = "Unknown OS" + if (/Windows/.test(ua)) os = "Windows" + else if (/Mac OS X|Macintosh/.test(ua)) os = "macOS" + else if (/Android/.test(ua)) os = "Android" + else if (/iPhone|iPad|iPod/.test(ua)) os = "iOS" + else if (/Linux/.test(ua)) os = "Linux" + + let browser = "Unknown browser" + if (/Edg\//.test(ua)) browser = "Edge" + else if (/OPR\//.test(ua)) browser = "Opera" + else if (/Chrome\//.test(ua)) browser = "Chrome" + else if (/Firefox\//.test(ua)) browser = "Firefox" + else if (/Safari\//.test(ua)) browser = "Safari" + + return `${browser} on ${os}` +} + +export function formatRelativeTime(iso: string | null): string { + if (!iso) return "Unknown" + + const diffSec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (diffSec < 60) return "Just now" + + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + + const diffDay = Math.floor(diffHr / 24) + return `${diffDay}d ago` +} From cc446bbae994e8c1f42127a8e1316e13db896101 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 28 Jul 2026 13:17:17 -0700 Subject: [PATCH 34/55] style(settings): card-style sections and sticky nav rail --- frontend/app/settings/account/page.tsx | 11 ++++---- frontend/app/settings/security/page.tsx | 5 ++-- frontend/components/settings/SettingsNav.tsx | 11 ++++++++ .../settings/SettingsPageHeading.tsx | 16 +++++++++++ frontend/components/settings/SettingsRow.tsx | 27 ++++++++++++------- 5 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 frontend/components/settings/SettingsPageHeading.tsx diff --git a/frontend/app/settings/account/page.tsx b/frontend/app/settings/account/page.tsx index 8a74365c..5cc8ab3c 100644 --- a/frontend/app/settings/account/page.tsx +++ b/frontend/app/settings/account/page.tsx @@ -10,7 +10,8 @@ import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; -import { SettingsRow } from "@/components/settings/SettingsRow"; +import { SettingsRow, SettingsSection } from "@/components/settings/SettingsRow"; +import { SettingsPageHeading } from "@/components/settings/SettingsPageHeading"; import { ChangeEmailModal } from "@/components/settings/ChangeEmailModal"; interface ProfileDraft { @@ -129,11 +130,9 @@ export default function AccountSettingsPage() { return (
-

- Account -

+ -
+ -
+ {showEmailModal && ( setShowEmailModal(false)} /> diff --git a/frontend/app/settings/security/page.tsx b/frontend/app/settings/security/page.tsx index 5467f46f..a2899550 100644 --- a/frontend/app/settings/security/page.tsx +++ b/frontend/app/settings/security/page.tsx @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; import { Banner } from "@/components/ui/Banner"; import { SettingsRow, SettingsSection } from "@/components/settings/SettingsRow"; +import { SettingsPageHeading } from "@/components/settings/SettingsPageHeading"; import { PasswordChecklist } from "@/components/settings/PasswordChecklist"; import { SessionList } from "@/components/settings/SessionList"; @@ -75,9 +76,7 @@ export default function SecuritySettingsPage() { return (
-

- Security -

+
diff --git a/frontend/components/settings/SettingsNav.tsx b/frontend/components/settings/SettingsNav.tsx index f20fb3b7..ffad4e9c 100644 --- a/frontend/components/settings/SettingsNav.tsx +++ b/frontend/components/settings/SettingsNav.tsx @@ -15,7 +15,18 @@ export function SettingsNav() { return (
+ ); } diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 0d07eb1e..ef6e32ad 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -88,6 +88,17 @@ export function IconShield({ size = 18, ...props }: IconProps) { ); } +export function IconMenu({ size = 18, ...props }: IconProps) { + return ( + + + + ); +} + // ------------------------------------------------------------------------- // Actions // ------------------------------------------------------------------------- diff --git a/frontend/lib/useIsMobile.ts b/frontend/lib/useIsMobile.ts new file mode 100644 index 00000000..0440dd83 --- /dev/null +++ b/frontend/lib/useIsMobile.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react" + +const MOBILE_BREAKPOINT_PX = 640 + +// Tracks a single (max-width: 640px) media query. Layout code reads this to +// switch between desktop and mobile arrangements without a CSS file. +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false) + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`) + setIsMobile(mql.matches) + + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener("change", handler) + return () => mql.removeEventListener("change", handler) + }, []) + + return isMobile +} From 4cffe130541615785bd78e5076c693434a0be4b1 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 30 Jul 2026 08:03:25 -0700 Subject: [PATCH 55/55] fix(settings): uneven padding in settings section --- frontend/app/settings/security/page.tsx | 2 +- frontend/components/settings/SettingsRow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/settings/security/page.tsx b/frontend/app/settings/security/page.tsx index 555208e2..4d7b512f 100644 --- a/frontend/app/settings/security/page.tsx +++ b/frontend/app/settings/security/page.tsx @@ -142,7 +142,7 @@ export default function SecuritySettingsPage() {
)} - diff --git a/frontend/components/settings/SettingsRow.tsx b/frontend/components/settings/SettingsRow.tsx index 006945ed..b126aed3 100644 --- a/frontend/components/settings/SettingsRow.tsx +++ b/frontend/components/settings/SettingsRow.tsx @@ -48,7 +48,7 @@ export function SettingsSection({ title, children, variant = "normal" }: Setting border: `1px solid ${variant === "danger" ? "var(--color-danger)" : "var(--color-border)"}`, borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-sm)", - padding: "8px 28px 28px", + padding: "8px 28px", }}> {title && (