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/alembic/versions/3cb43e5b0a3f_make_first_name_and_last_name_nullable_.py b/backend/alembic/versions/3cb43e5b0a3f_make_first_name_and_last_name_nullable_.py new file mode 100644 index 00000000..c656e01a --- /dev/null +++ b/backend/alembic/versions/3cb43e5b0a3f_make_first_name_and_last_name_nullable_.py @@ -0,0 +1,36 @@ +"""make first_name and last_name nullable again + +Revision ID: 3cb43e5b0a3f +Revises: d10cb65643a2 +Create Date: 2026-07-28 16:04:44.299156 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3cb43e5b0a3f' +down_revision: Union[str, None] = 'd10cb65643a2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column('users', 'first_name', + existing_type=sa.VARCHAR(length=100), + nullable=True) + op.alter_column('users', 'last_name', + existing_type=sa.VARCHAR(length=100), + nullable=True) + + +def downgrade() -> None: + op.alter_column('users', 'last_name', + existing_type=sa.VARCHAR(length=100), + nullable=False) + op.alter_column('users', 'first_name', + existing_type=sa.VARCHAR(length=100), + nullable=False) 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/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 53e26a5f..8b0d559e 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,96 +1,70 @@ -from fastapi import APIRouter, Depends, HTTPException, Response, status -from sqlalchemy.orm import Session 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, + get_current_session, + revoke_session, + revoke_all_sessions, + revoke_all_other_sessions, hash_password, verify_password, get_current_user, require_admin, + set_auth_cookie, + clear_auth_cookie, + consume_verification_token, + get_pending_email_change, + RATE_LIMIT_WINDOW, ) -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.db.session import get_db -from app.models.models import User +from app.models.models import User, UserSession, VerificationToken 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, - ) +from app.schemas.auth import ( + LoginRequest, + RegisterRequest, + AdminRegisterRequest, + MessageResponse, + EmailChangeRequest, + EmailPendingChangeResponse, + EmailChangeRevertConfirm, + PasswordChangeRequest, + PasswordResetRequest, + PasswordResetConfirm, + AccountSetupConfirm, + AccountSetupResendRequest, +) +from app.services.email_service import ( + send_signup_verification_email, + send_email_change_request_email, + send_email_change_requested_notice, + send_password_reset_request_email, + send_password_changed_notice, + send_account_setup_invite_email, +) +from datetime import datetime, timezone -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)) +router = APIRouter(tags=["auth"]) - except Exception: - raise HTTPException(500, "Failed to send verification email") +# --------------------------------------------------------------------------- +# Login / Logout +# --------------------------------------------------------------------------- @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(), - User.is_active == True, + User.status == "active", ).first() # Deliberate: same error whether email or password is wrong — prevents enumeration @@ -106,76 +80,379 @@ 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.""" - _clear_auth_cookie(response) +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"} +# --------------------------------------------------------------------------- +# 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)): +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". """ 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, password=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 @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, status="invited") + 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={ 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: + """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") - - 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), +): + """Resends the signup verification email for the current user.""" if user.email_verified: raise HTTPException(400, "Email already verified") - - await _send_verification_email(user.email, user.id) - return {"detail": "Verification email successfully sent"} \ No newline at end of file + await send_signup_verification_email(db, user.email, user.id) + + return {"detail": "Verification email successfully sent"} + + +# --------------------------------------------------------------------------- +# Email change +# --------------------------------------------------------------------------- + +@router.get("/auth/email/pending-change/", status_code=status.HTTP_200_OK, response_model=EmailPendingChangeResponse) +def get_pending_email_change_route( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Whether the current user has an email change awaiting confirmation.""" + token_row = get_pending_email_change(db, user.id) + if token_row is None: + return EmailPendingChangeResponse() + + return EmailPendingChangeResponse( + new_email=token_row.new_email, + can_resend_at=token_row.created_at + RATE_LIMIT_WINDOW, + ) + + +@router.post("/auth/email/request-change/", status_code=status.HTTP_200_OK, response_model=EmailPendingChangeResponse, + 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(). Also doubles + as the resend route: the frontend calls this again with the same + new_email to resend, rather than a separate no-arg resend endpoint. + + Also notifies the OLD address immediately (not on confirm) — user.email + is still the original address here, so it's captured before anything + is mutated. This is the only notice the account owner will see if they + weren't the one who made the request. + """ + 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) + await send_email_change_requested_notice(db, user.id, user.email, body.new_email) + + token_row = get_pending_email_change(db, user.id) + return EmailPendingChangeResponse( + new_email=token_row.new_email, + can_resend_at=token_row.created_at + RATE_LIMIT_WINDOW, + ) + + +@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) + + user.email = token_row.new_email + user.email_verified = True + db.commit() + + return {"detail": "Email successfully updated"} + + +@router.post("/auth/email/revert/", status_code=status.HTTP_200_OK, response_model=MessageResponse, + responses={ + 400: {"description": "Invalid or expired token"}, + }, +) +async def revert_email_change(body: EmailChangeRevertConfirm, db: Session = Depends(get_db)): + """ + Consumes an email_change_revert token. Handles both cases in one route, + since the token doesn't know which state it'll find things in: + + - Change was requested but never confirmed (still pending) — the + email_change token is still valid/unconsumed. Cancel it so it can't + be confirmed later. user.email is untouched since it was never + mutated in the first place. + - Change was already confirmed and applied — user.email currently holds + the attacker's address. Revert it to the address stored on this token. + + Either branch, or both, end the same way: reaching this route at all + means assume compromise — force a new password and revoke every + session, don't do a partial fix. + """ + token_row = consume_verification_token(db, body.token, "email_change_revert") + if token_row is None: + raise HTTPException(400, "Invalid or expired token") + + user = find_user_by_id(db, token_row.user_id) + original_email = token_row.new_email # reused column — see VerificationToken.new_email comment + + now = datetime.now(timezone.utc) + + # Case 1: still-pending email_change token — cancel it. + pending = db.query(VerificationToken).filter( + VerificationToken.user_id == user.id, + VerificationToken.purpose == "email_change", + VerificationToken.used_at.is_(None), + VerificationToken.expires_at > now, + ).first() + if pending: + pending.used_at = now + + # Case 2: already applied — this is a no-op if it wasn't. + user.email = original_email + user.email_verified = True # assumes the original address was verified pre-takeover + + user.hashed_password = hash_password(body.new_password) + db.commit() + + revoke_all_sessions(db, user.id) + + return {"detail": "Email reverted and account secured"} + + +# --------------------------------------------------------------------------- +# 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), + session: UserSession = Depends(get_current_session), + 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) + revoke_all_other_sessions(db, user.id, session.id) + + 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.status == "active", + ).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) + revoke_all_sessions(db, user.id) + + 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, + request: Request, + response: Response, + db: Session = Depends(get_db), +): + """ + Completes an admin-created account: sets the initial password. Logs the + user in immediately, same as register(), since this mirrors sign-up's + flow. Phone and any name correction happen afterward in onboarding. + """ + 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.status = "active" + db.commit() + db.refresh(user) + + 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("/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.status != "invited": + 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/api/routes/users.py b/backend/app/api/routes/users.py index b238b72a..1eb9f508 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,17 +1,22 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +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 -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.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 find_user_by_id +from app.core.profile_status import compute_missing_profile_fields, is_profile_complete, is_onboarding_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"]) @@ -70,10 +75,21 @@ 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. + + 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 @@ -99,7 +115,7 @@ def admin_delete_user( # GET /users/me/ — current user's own profile. ?full=true for full response # with experience lists eagerly loaded. # --------------------------------------------------------------------------- -@router.get("/users/me/", response_model=Union[UserMeFullResponse, UserMeSlimResponse]) +@router.get("/users/me/", response_model=None) def get_me( full: bool = False, db: Session = Depends(get_db), @@ -121,6 +137,7 @@ def get_me( response = UserMeSlimResponse.model_validate(current_user) response.is_profile_complete = is_profile_complete(current_user, db=db) + response.is_onboarding_complete = is_onboarding_complete(current_user) return response @@ -136,15 +153,117 @@ 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) - + 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"} + + +# --------------------------------------------------------------------------- +# 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/core/auth.py b/backend/app/core/auth.py index 54718acf..e49e9484 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -2,23 +2,25 @@ 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 Tournament-level permission checking lives in app/core/permissions.py. """ +import hashlib +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 +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 +from app.models.models import User, VerificationToken, UserSession # --------------------------------------------------------------------------- # Password hashing @@ -36,44 +38,312 @@ 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: +def _hash_session_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode()).hexdigest() + + +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 +# --------------------------------------------------------------------------- + +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() - 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) + 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 decode_access_token(token: str) -> Optional[int]: - """Returns user_id from a valid token, or None if invalid/expired.""" +def clear_auth_cookie(response: Response) -> None: 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: - return None + 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. +# 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", "account_setup", "email_change_revert"] + +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 + "email_change_revert": timedelta(hours=24), +} + +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 get_pending_email_change(db: Session, user_id: int) -> Optional[VerificationToken]: + """ + Returns the user's active (unconsumed, unexpired) email_change token, if + any. create_verification_token invalidates older tokens for the same + (user_id, purpose) on each new request, so at most one row can ever + match — no ordering/tiebreak needed. + """ + now = datetime.now(timezone.utc) + + return ( + db.query(VerificationToken) + .filter( + VerificationToken.user_id == user_id, + VerificationToken.purpose == "email_change", + VerificationToken.used_at.is_(None), + VerificationToken.expires_at > now, + ) + .first() + ) + + +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 # --------------------------------------------------------------------------- -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, @@ -82,13 +352,29 @@ 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 isn't active. + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + user = db.query(User).filter( - User.id == user_id, - User.is_active == True, + User.id == session_row.user_id, + User.status == "active", ).first() if user is None: raise credentials_exception 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/core/profile_status.py b/backend/app/core/profile_status.py index 4e1786ff..e4264ee0 100644 --- a/backend/app/core/profile_status.py +++ b/backend/app/core/profile_status.py @@ -27,7 +27,7 @@ def _has_volunteer_rows(user: User, db: Optional[Session]) -> bool: def compute_missing_profile_fields(user: User, *, db: Optional[Session] = None) -> list[str]: - always_required = ["phone", "date_of_birth", "shirt_size", "dietary_restriction"] + always_required = ["first_name", "last_name", "phone", "date_of_birth", "shirt_size", "dietary_restriction"] missing = [f for f in always_required if not getattr(user, f)] if not user.student_status: @@ -54,4 +54,13 @@ def compute_missing_profile_fields(user: User, *, db: Optional[Session] = None) def is_profile_complete(user: User, *, db: Optional[Session] = None) -> bool: - return not compute_missing_profile_fields(user, db=db) \ No newline at end of file + return not compute_missing_profile_fields(user, db=db) + + +ONBOARDING_REQUIRED = ["first_name", "last_name", "phone", "date_of_birth"] + +def compute_missing_onboarding_fields(user: User) -> list[str]: + return [f for f in ONBOARDING_REQUIRED if not getattr(user, f)] + +def is_onboarding_complete(user: User) -> bool: + return not compute_missing_onboarding_fields(user) \ No newline at end of file diff --git a/backend/app/core/users.py b/backend/app/core/users.py index f6a6f8d3..99d59812 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: Optional[str] = None, + last_name: Optional[str] = None, + role: str = "user", + phone: Optional[str] = None, + password: Optional[str] = None, + status: str = "active", +) -> 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, + status=status, + ) + db.add(user) + db.commit() + db.refresh(user) return user \ No newline at end of file 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 23de4f08..47ba3652 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -23,6 +23,70 @@ 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" | "email_change_revert" +# new_email is only ever set for "email_change" and "email_change_revert" rows. +# For "email_change_revert" it's overloaded to mean "the email this token +# reverts TO" (i.e. the pre-takeover address), not "the new email". +# +# 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" | "email_change_revert" + new_email = Column(String(255), nullable=True) # "email_change": new address. "email_change_revert": address to revert TO (old address). + + 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. @@ -42,8 +106,8 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) - first_name = Column(String(100), nullable=False) - last_name = Column(String(100), nullable=False) + first_name = Column(String(100), nullable=True) + last_name = Column(String(100), nullable=True) email = Column(String(255), unique=True, index=True, nullable=False) phone = Column(String(32), nullable=True) date_of_birth = Column(Date, nullable=True) @@ -53,7 +117,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) @@ -109,8 +173,6 @@ class UserCompetitionExperience(Base): user = relationship("User", back_populates="competition_experience") event = relationship("Event", back_populates="user_competition_experience") - - # --------------------------------------------------------------------------- # Volunteer Experience # @@ -318,30 +380,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 # --------------------------------------------------------------------------- @@ -374,4 +412,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 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 0bc54454..737f01d2 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,9 +1,7 @@ -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 -from app.core.phone import normalize_phone as _normalize_phone - class LoginRequest(BaseModel): email: EmailStr @@ -18,55 +16,56 @@ 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 password: str - first_name: str - last_name: str # role is intentionally excluded — all publicly registered users are "user". - - @field_validator("phone") - @classmethod - def normalize_phone(cls, phone: str) -> str: - return _normalize_phone(phone) - + # name and phone are collected later in the shared /onboarding step. @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 +78,128 @@ 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 EmailPendingChangeResponse(BaseModel): + """ + GET /auth/email/pending-change — authenticated. + Also the response shape for POST /auth/email/request-change. + + Both fields null together means no email change is currently pending. + """ + new_email: Optional[str] = None + can_resend_at: Optional[datetime] = None + + +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) + +class EmailChangeRevertConfirm(BaseModel): + """ + POST /auth/email/revert — logged out, token + new password. + + Consumes an 'email_change_revert' token — undoes an in-progress or + completed email change and forces a new password, since reaching this + route at all means assume account compromise. + """ + token: str + new_password: str + + @field_validator("new_password") + @classmethod + def check_new_password(cls, password: str) -> str: + 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. Mirrors sign-up's + phase-1 fields, minus email (locked to the invited address). Phone and + any name correction are handled afterward in the shared /onboarding step, + same as regular sign-up users. + + Does NOT set email_verified — that still requires the normal verification flow. + """ + token: str + password: str + + @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 + + +# --------------------------------------------------------------------------- +# 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/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/app/schemas/user.py b/backend/app/schemas/user.py index 53f9fb9a..12a15250 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"] @@ -15,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 @@ -34,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: @@ -48,21 +48,21 @@ 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 class UserSlimResponse(BaseModel): id: int - first_name: str - last_name: str + first_name: Optional[str] = None + last_name: Optional[str] = None email: EmailStr phone: Optional[str] = None pronouns: Optional[str] = None email_verified: bool role: ROLE - is_active: bool + status: USER_STATUS created_at: datetime updated_at: datetime @@ -71,6 +71,7 @@ class UserSlimResponse(BaseModel): class UserMeSlimResponse(UserSlimResponse): is_profile_complete: bool = False + is_onboarding_complete: bool = False diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 525a4246..451f081f 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,17 +1,328 @@ 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) \ No newline at end of file + 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") + 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") + + +# --------------------------------------------------------------------------- +# Email change +# --------------------------------------------------------------------------- + +async def send_email_change_email(to_new_email: str, token: str) -> None: + url = _cta_url("/confirm-email-change", 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_change_requested_notice(db: Session, user_id: int, old_email: str, new_email: str) -> None: + """ + Sent to the OLD email address the moment an email change is requested + (not once it's confirmed) — the account owner may not be the one who + requested it, so this needs to reach them while the change can still be + intercepted, not just after it's already applied. + + Creates the email_change_revert token itself and embeds it in the CTA + link, so the recipient can undo the change directly instead of landing + on /forgot-password with no way to look themselves up (their email is + about to become the attacker's address). + """ + try: + revert_token = create_verification_token(db, user_id, "email_change_revert", new_email=old_email) + except RateLimitedError as e: + raise HTTPException(429, str(e)) + + url = _cta_url("/revert-email-change", revert_token) + html = _render_email_html( + heading="Your email address is being changed", + body_lines=[ + f"A request was made to change the email on your NEXUS account to {new_email}.", + "If you made this request, no action is needed.", + ], + cta_label="Secure your account", + cta_url=url, + footnote=_CONTACT_SUPPORT, + ) + await _send( + old_email, + "Your NEXUS account email is being changed", + f"A request was made to change your account email 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 support.", + ) + 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 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 diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index e0ff8a25..1f9dab70 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 @@ -15,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() @@ -30,7 +29,23 @@ def volunteer_no_password(db): first_name="Volunteer", last_name="NoPassword", role="user", - is_active=True, + status="active", + ) + db.add(user) + db.commit() + db.refresh(user) + 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() @@ -127,26 +142,20 @@ class TestRegister: def test_register_success(self, client): res = client.post("/auth/register/", json={ "email": "new@test.com", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }) assert res.status_code == 201 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): # Registration should log the user in immediately res = client.post("/auth/register/", json={ "email": "new@test.com", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }) assert res.status_code == 201 assert "access_token" in res.cookies @@ -155,10 +164,7 @@ def test_register_auto_login(self, client): # Cookie from registration should allow immediate access to /me/ client.post("/auth/register/", json={ "email": "new@test.com", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }) res = client.get("/users/me/") assert res.status_code == 200 @@ -167,10 +173,7 @@ def test_register_auto_login(self, client): def test_register_email_stored_lowercase(self, client): res = client.post("/auth/register/", json={ "email": "NEW@TEST.COM", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }) assert res.status_code == 201 assert res.json()["email"] == "new@test.com" @@ -178,77 +181,53 @@ def test_register_email_stored_lowercase(self, client): def test_register_duplicate_email_rejected(self, client, td_user): assert client.post("/auth/register/", json={ "email": "td@test.com", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }).status_code == 409 def test_register_duplicate_email_case_insensitive_rejected(self, client, td_user): assert client.post("/auth/register/", json={ "email": "TD@TEST.COM", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }).status_code == 409 - def test_register_invalid_phone_rejected(self, client): - assert client.post("/auth/register/", json={ - "email": "new@test.com", - "phone": "notaphone", - "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", - }).status_code == 422 - def test_register_can_login_with_new_credentials(self, client): # Confirms the password was hashed and stored correctly client.post("/auth/register/", json={ "email": "new@test.com", - "phone": VALID_PHONE, "password": VALID_PASSWORD, - "first_name": "New", - "last_name": "User", }) client.post("/auth/logout/") assert login(client, "new@test.com", VALID_PASSWORD).status_code == 200 def test_register_password_too_short(self, client): assert client.post("/auth/register/", json={ - "email": "new@test.com", "phone": VALID_PHONE, "password": "Ab@1", - "first_name": "New", "last_name": "User", + "email": "new@test.com", "password": "Ab@1", }).status_code == 422 def test_register_password_missing_uppercase(self, client): assert client.post("/auth/register/", json={ - "email": "new@test.com", "phone": VALID_PHONE, "password": "secure@123", - "first_name": "New", "last_name": "User", + "email": "new@test.com", "password": "secure@123", }).status_code == 422 def test_register_password_missing_lowercase(self, client): assert client.post("/auth/register/", json={ - "email": "new@test.com", "phone": VALID_PHONE, "password": "SECURE@123", - "first_name": "New", "last_name": "User", + "email": "new@test.com", "password": "SECURE@123", }).status_code == 422 def test_register_password_missing_number(self, client): assert client.post("/auth/register/", json={ - "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure@abc", - "first_name": "New", "last_name": "User", + "email": "new@test.com", "password": "Secure@abc", }).status_code == 422 def test_register_password_missing_symbol(self, client): assert client.post("/auth/register/", json={ - "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure1234", - "first_name": "New", "last_name": "User", + "email": "new@test.com", "password": "Secure1234", }).status_code == 422 def test_register_password_invalid_char(self, client): # Control characters (ASCII < 32) should be rejected assert client.post("/auth/register/", json={ - "email": "new@test.com", "phone": VALID_PHONE, "password": "Secure@1\x01", - "first_name": "New", "last_name": "User", + "email": "new@test.com", "password": "Secure@1\x01", }).status_code == 422 @@ -282,7 +261,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", @@ -291,7 +270,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 @@ -349,7 +328,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 +337,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 # --------------------------------------------------------------------------- @@ -379,4 +359,288 @@ 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_request_change_returns_pending_state(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"}) + body = res.json() + assert body["new_email"] == "tdnew@test.com" + assert body["can_resend_at"] is not None + + 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/pending-change/ +# --------------------------------------------------------------------------- + +class TestPendingEmailChange: + def test_nothing_pending_returns_nulls(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.get("/auth/email/pending-change/") + assert res.status_code == 200 + assert res.json() == {"new_email": None, "can_resend_at": None} + + def test_pending_change_reflects_request(self, client, td_user, mock_send_email): + login(client, "td@test.com", "tdpass") + client.post("/auth/email/request-change/", json={"new_email": "tdnew@test.com"}) + + res = client.get("/auth/email/pending-change/") + assert res.status_code == 200 + body = res.json() + assert body["new_email"] == "tdnew@test.com" + assert body["can_resend_at"] is not None + + def test_consumed_token_not_reported_as_pending(self, client, td_user, db): + token = create_verification_token(db, td_user.id, "email_change", new_email="tdnew@test.com") + client.get(f"/auth/email/confirm-change/?token={token}") + + login(client, "tdnew@test.com", "tdpass") + res = client.get("/auth/email/pending-change/") + assert res.json() == {"new_email": None, "can_resend_at": None} + + def test_unauthenticated_forbidden(self, client): + assert client.get("/auth/email/pending-change/").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, + }) + 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 + + 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, + }) + assert client.get("/users/me/").status_code == 200 + + def test_invalid_token_rejected(self, client): + res = client.post("/auth/account-setup/confirm/", json={ + "token": "garbage", + "password": VALID_PASSWORD, + }) + 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 diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index 1a193415..22166792 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): @@ -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") @@ -156,6 +172,131 @@ 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/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/ # --------------------------------------------------------------------------- @@ -247,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): - 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): + 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") - 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") @@ -283,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") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7febc2cb..6695df6d 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 @@ -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() @@ -219,6 +219,20 @@ 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 _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", mock) + return mock + + @pytest.fixture(scope="function") def client(db, mock_sheets_service, mock_forms_service): from app.main import app diff --git a/frontend/app/(auth)/account-setup/page.tsx b/frontend/app/(auth)/account-setup/page.tsx new file mode 100644 index 00000000..ad1490a7 --- /dev/null +++ b/frontend/app/(auth)/account-setup/page.tsx @@ -0,0 +1,76 @@ +'use client' + +import { Suspense, useState } from "react" +import { useSearchParams } from "next/navigation" +import { authApi, ApiError } from "@/lib/api" +import { Spinner } from "@/components/ui/Spinner" +import { CredentialsForm } from "@/components/auth/CredentialsForm" + +export default function AccountSetupPage() { + return ( + }> + + + ) +} + +function AccountSetupContent() { + const searchParams = useSearchParams() + const token = searchParams.get("token") ?? '' + + const [loading, setLoading] = useState(false) + const [formError, setFormError] = useState(undefined) + + async function handleConfirm(password: string) { + setLoading(true) + setFormError(undefined) + + try { + await authApi.confirmAccountSetup(token, password) + + // Full reload (not router.push) — AuthProvider needs to fetch the + // now-authenticated session fresh, same reasoning as onboarding/sign-up. + window.location.href = "/onboarding" + } catch (error: unknown) { + setFormError(error instanceof ApiError ? error.message : "Something went wrong :(") + } finally { + setLoading(false) + } + } + + if (!token) { + return ( +

+ This link is missing or invalid. Ask whoever invited you to resend it. +

+ ) + } + + return ( +
+

Set up your account

+ +

Choose a password to finish setting up your account.

+ + +
+ ) +} 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..0e8bbcc2 --- /dev/null +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,68 @@ +'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" +import { Banner } from "@/components/ui/Banner" + +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 ? ( + + ) : ( +
+ { 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..be0d8f9c 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -1,9 +1,44 @@ +'use client' + import { ReactNode } from 'react' +import { useRouter, usePathname } from 'next/navigation' +import { IconArrowLeft } from '@/components/ui/Icons' + +// Pages that show a "Back to home" link above the wordmark. +const BACK_TO_HOME_ROUTES = ['/sign-in', '/sign-up'] export default function AuthLayout({ children }: { children: ReactNode }) { + const router = useRouter() + const pathname = usePathname() + return ( -
- {children} +
+
+ {BACK_TO_HOME_ROUTES.includes(pathname) && ( +
+ +
+ )} +

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..3ceb86c2 --- /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/auth/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)/revert-email-change/page.tsx b/frontend/app/(auth)/revert-email-change/page.tsx new file mode 100644 index 00000000..8beada7e --- /dev/null +++ b/frontend/app/(auth)/revert-email-change/page.tsx @@ -0,0 +1,93 @@ +'use client' + +import { Suspense, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { authApi, ApiError } from "@/lib/api" +import { Button } from "@/components/ui/Button" +import { Spinner } from "@/components/ui/Spinner" +import { IconCheckCircle } from "@/components/ui/Icons" +import { CredentialsForm } from "@/components/auth/CredentialsForm" + +export default function RevertEmailChangePage() { + return ( + }> + + + ) +} + +function RevertEmailChangeContent() { + const searchParams = useSearchParams() + const token = searchParams.get("token") ?? '' + const router = useRouter() + + const [loading, setLoading] = useState(false) + const [formError, setFormError] = useState(undefined) + const [success, setSuccess] = useState(false) + + async function handleRevert(password: string) { + setLoading(true) + setFormError(undefined) + + try { + await authApi.revertEmailChange(token, password) + // Backend has already revoked every session — no cookie to refresh + // into, so this is a plain success state, not a redirect. + setSuccess(true) + } catch (error: unknown) { + setFormError(error instanceof ApiError ? error.message : "Something went wrong :(") + } finally { + setLoading(false) + } + } + + if (!token) { + return ( +

+ This link is missing or invalid. +

+ ) + } + + return ( +
+

Secure your account

+ + {!success && ( +

Choose a new password to undo the email change and secure your account.

+ )} + + {success ? ( +
+ + Your account has been secured. + +

+ The email change was reverted and every session was signed out. Please sign in again. +

+ +
+ ) : ( + + )} +
+ ) +} diff --git a/frontend/app/(auth)/sign-in/page.tsx b/frontend/app/(auth)/sign-in/page.tsx index 86012eeb..a6368550 100644 --- a/frontend/app/(auth)/sign-in/page.tsx +++ b/frontend/app/(auth)/sign-in/page.tsx @@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation' import { ApiError, authApi } from '@/lib/api' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' -import { IconArrowLeft } from '@/components/ui/Icons' import { validateEmail } from '@/lib/auth' @@ -34,7 +33,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 }) @@ -47,34 +46,15 @@ export default function SignInPage() { } return ( -
-
- -
- -
-

NEXUS

-
- -
-

Sign In

-
+
+

Sign In

- { setPassword(e.target.value); setErrors(er => ({...er, password: undefined})) }} - autoComplete="current-password" - error={errors.password} - fullWidth - /> +
+ { setPassword(e.target.value); setErrors(er => ({...er, password: undefined})) }} + autoComplete="current-password" + error={errors.password} + fullWidth + /> +
+ +
+
- + +
+ + Don’t have an account?{' '} + + +
+
+ ) } diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index 0e32a916..2e38bc82 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -1,188 +1,31 @@ 'use client' -import { useEffect, useState } from "react" -import { - authApi, ApiError, CanonicalEvent, canonicalEventsApi, - STUDENT_STATUS, SHIRT_SIZE, UserSlim, usersApi -} from "@/lib/api" +import { useState } from "react" +import { authApi, ApiError } from "@/lib/api" import { useRouter } from "next/navigation" -import { checkPassword, formatPhone, validateEmail, validatePassword, validatePhone, validateDateOfBirth } from "@/lib/auth" -import { IconArrowLeft, IconCheckCircle, IconXCircle } from "@/components/ui/Icons" -import { Input } from "@/components/ui/Input" +import { IconCheckCircle } from "@/components/ui/Icons" 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, - UniversityField, MajorField, YearLevelField, GraduationYearField, - EmployerField, YesNoField, ShirtSizeField, DietaryRestrictionField, -} from "@/components/profile/ProfileFields" -import { - CompetitionExperienceSpreadsheet, CompetitionExperienceDraft, isCompetitionRowValid, - VolunteerExperienceSpreadsheet, VolunteerExperienceDraft, isVolunteerRowValid -} from "@/components/profile/ExperienceTables" -import { useFormattedInputChange } from "@/lib/useFormattedInput" - - -function setCookie() { - document.cookie = "inSignUpFlow=true; path=/" -} - -function clearCookie() { - document.cookie = "inSignUpFlow=; path=/; max-age=0" -} - -const STATE = { - ACCOUNT: 1, - DATE_OF_BIRTH: 2, - PRONOUNS: 3, - STUDENT_STATUS: 4, - UNIVERSITY: 5, - EMPLOYER: 6, - COMPETED_BEFORE: 7, - COMPETITION_EXP: 8, - VOLUNTEERED_BEFORE: 9, - VOLUNTEERING_EXP: 10, - SHIRT_SIZE: 11, - DIETARY_RESTRICTIONS: 12, - DIETARY_TEXT: 13, - COMPLETE: 14, -} as const +import { CredentialsForm } from "@/components/auth/CredentialsForm" export default function SignUpPage() { - const [state, setState] = useState(STATE.ACCOUNT) - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(false) - - const [name, setName] = useState<{ first: string, last: string }>({ first: '', last: '' }) const [email, setEmail] = useState('') - const [phone, setPhone] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [passwordChecks, setPasswordChecks] = useState<{ - length: boolean - upper: boolean - lower: boolean - number: boolean - symbol: boolean - confirm: boolean - }>({ length: false, upper: false, lower: false, number: false, symbol: false, confirm: false }) - - const handlePhoneChange = useFormattedInputChange( - phone, - setPhone, - formatPhone, - (v) => v.replace(/\D/g, '').slice(0, 10), - ) - + const [loading, setLoading] = useState(false) + const [formError, setFormError] = useState(undefined) const [showVerifyModal, setShowVerifyModal] = useState(false) - const [profileData, setProfileData] = useState<{ - date_of_birth?: string - pronouns?: string - - student_status?: STUDENT_STATUS - university?: string - major?: string - year_level?: number - graduation_year?: number - - employer?: string - - has_competition_experience?: boolean - has_volunteer_experience?: boolean - - shirt_size?: SHIRT_SIZE - dietary_restriction?: string - }>({}) - - const [events, setEvents] = useState([]) - const [competitionRows, setCompetitionRows] = useState([]) - const [volunteerRows, setVolunteerRows] = useState([]) - - const [hasDietary, setHasDietary] = useState(null) - - - const [errors, setErrors] = useState<{ - first_name?: string - last_name?: string - email?: string - phone?: string - password?: string - confirm_password?: string - form1?: string - - date_of_birth?: string - pronouns?: string - student_status?: string - university?: string - major?: string - year_level?: string - graduation_year?: string - employer?: string - competition_exp?: string - volunteering_exp?: string - shirt_size?: string - dietary_restriction?: string - form2?: string - }>({}) - const router = useRouter() - useEffect(() => { - usersApi.me().then(user => { - if (document.cookie.includes("inSignUpFlow")) { - setUser(user) - setState(STATE.DATE_OF_BIRTH) - } else { - router.push('/dashboard') - } - }).catch(() => {}) - - canonicalEventsApi.list().then(setEvents).catch(() => {}) - }, []) - - async function handleRegisterSubmit(e: React.SyntheticEvent) { - e.preventDefault() + async function handleRegister(password: string) { setLoading(true) - setErrors({}) - - const registerErrors = { - first_name: !name.first ? "Cannot be empty." : undefined, - last_name: !name.last ? "Cannot be empty." : undefined, - email: validateEmail(email) ?? undefined, - phone: validatePhone(phone) ?? undefined, - password: validatePassword(password) ?? undefined, - confirm_password: password !== confirmPassword ? "Passwords don't match." : undefined - } - - if (Object.values(registerErrors).some(v => v)) { - setErrors(registerErrors) - setLoading(false) - return - } + setFormError(undefined) try { - setUser(await authApi.register({ - email: email, - phone: phone, - password: password, - first_name: name.first, - last_name: name.last - })) + await authApi.register({ email, password }) - setCookie() - - authApi.sendEmailVerification().then(() => { - setShowVerifyModal(true) - }).catch(() => {}).finally(() => setState(STATE.DATE_OF_BIRTH)) + authApi.sendEmailVerification().catch(() => {}).finally(() => setShowVerifyModal(true)) } catch (error: unknown) { - if (error instanceof ApiError) { - setErrors({ form1: error.message }) - } else { - setErrors({ form1: "Something went wrong :(" }) - } + setFormError(error instanceof ApiError ? error.message : "Something went wrong :(") } finally { setLoading(false) } @@ -196,588 +39,43 @@ export default function SignUpPage() {

Your account was created successfully

-

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

- +

We sent a verification link to {email}

+ ) } - async function handleProfileSubmit(e: React.SyntheticEvent) { - e.preventDefault() - setLoading(true) - setErrors({}) - - const cleaned: Record = { ...profileData } - - if (cleaned.student_status === 'Undergraduate' || cleaned.student_status === 'Graduate') { - cleaned.employer = undefined - } else if (cleaned.student_status === 'Non-Student') { - cleaned.university = cleaned.major = cleaned.year_level = cleaned.graduation_year = undefined - } else { - cleaned.student_status = cleaned.employer = cleaned.university = cleaned.major = cleaned.year_level = cleaned.graduation_year = undefined - } - - for (const key of Object.keys(cleaned)) { - if (cleaned[key] === "") cleaned[key] = undefined - } - - const dobErr = cleaned.date_of_birth ? validateDateOfBirth(cleaned.date_of_birth as string) : null - if (dobErr) { - setErrors(er => ({ ...er, date_of_birth: dobErr })) - setLoading(false) - return - } - - if (cleaned.pronouns !== undefined && !(cleaned.pronouns as string).trim()) { - setErrors(er => ({ ...er, pronouns: "Cannot be empty." })) - setLoading(false) - return - } - - if (cleaned.graduation_year && ((cleaned.graduation_year as number) < 1000 || (cleaned.graduation_year as number) > 9999)) { - setErrors(er => ({ ...er, graduation_year: "Must be a valid year." })) - setLoading(false) - return - } - - try { - await usersApi.updateMe(cleaned) - - if (profileData.has_competition_experience) { - await Promise.all(competitionRows.map(row => - usersApi.addCompetitionExperience({ - event_id: row.event_id as number, - school: row.school, - notes: row.notes || null, - }) - )) - } - - if (profileData.has_volunteer_experience) { - await Promise.all(volunteerRows.map(row => - usersApi.addVolunteerExperience({ - tournament_name: row.tournament_name, - year: Number(row.year), - role: row.role, - event_id: row.event_id ?? undefined, - notes: (row.event_id === null && row.event_name.trim()) || row.notes_other.trim() - ? { - ...(row.event_id === null && row.event_name.trim() ? { event: row.event_name.trim() } : {}), - ...(row.notes_other.trim() ? { other: row.notes_other.trim() } : {}), - } - : undefined, - }) - )) - } - - clearCookie() - router.push("/dashboard") - } catch (error: unknown) { - if (error instanceof ApiError) { - setErrors({ form2: error.message }) - } else { - setErrors({ form2: "Something went wrong :(" }) - } - } finally { - setLoading(false) - } - } - return ( - <> - {state === STATE.ACCOUNT && ( -
-
- -
- -
-

NEXUS

-
- -
-

Sign Up

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

- {errors.form1} -

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

NEXUS

-
- -
-

Complete Your Profile

-
-
- setState(STATE.PRONOUNS)} - onNext={() => { - const err = validateDateOfBirth(profileData.date_of_birth ?? '') - if (err) { - setErrors(er => ({ ...er, date_of_birth: err })) - return - } - setState(STATE.PRONOUNS) - }} - isActive={state === STATE.DATE_OF_BIRTH} - > { - setProfileData(d => ({ ...d, date_of_birth: e.target.value })) - setErrors(er => ({ ...er, date_of_birth: undefined })) - }} - error={errors.date_of_birth} - fullWidth - /> - - - {state >= STATE.PRONOUNS && ( - setState(STATE.STUDENT_STATUS)} - onNext={() => { - if (!profileData.pronouns?.trim()) { - setErrors(er => ({ ...er, pronouns: "Cannot be empty." })) - return - } - setState(STATE.STUDENT_STATUS) - }} - isActive={state === STATE.PRONOUNS} - > - { - setProfileData(d => ({ ...d, pronouns: v })) - setErrors(er => ({ ...er, pronouns: undefined })) - }} - /> - - )} - - { state >= STATE.STUDENT_STATUS && ( - setState(STATE.STUDENT_STATUS + 3)} - isActive={state === STATE.STUDENT_STATUS} - > - { - setProfileData(d => ({...d, student_status: v})) - if (state >= (STATE.STUDENT_STATUS + 3)) return - if (v === 'Undergraduate' || v === 'Graduate') { - setState(STATE.UNIVERSITY) - } else if (v === 'Non-Student') { - setState(STATE.EMPLOYER) - } - }} - /> - - )} - - { state >= STATE.UNIVERSITY && (profileData.student_status === "Undergraduate" || profileData.student_status === "Graduate") && ( -
- - { - setProfileData(d => ({...d, university: v})) - setErrors(er => ({...er, university: undefined})) - }} - /> - - - { - setProfileData(d => ({...d, major: v})) - setErrors(er => ({...er, major: undefined})) - }} - /> - - - { - setProfileData(d => ({...d, year_level: v})) - setErrors(er => ({...er, year_level: undefined})) - }} - /> - - { - setProfileData(d => ({...d, university: undefined, major: undefined, year_level: undefined, graduation_year: undefined})) - setState(STATE.UNIVERSITY + 2) - }} - onNext={() => { - const ers: typeof errors = {} - - if (!profileData.university) ers.university = "Cannot be empty." - if (!profileData.major) ers.major = "Cannot be empty." - if (!profileData.year_level) ers.year_level = "Cannot be empty." - - if (!profileData.graduation_year) ers.graduation_year = "Cannot be empty." - else if (profileData.graduation_year < 1000 || profileData.graduation_year > 9999) - ers.graduation_year = "Must be a valid year." - - Object.keys(ers).length > 0 ? setErrors(er => ({...er, ...ers})) : setState(STATE.UNIVERSITY + 2) - }} - isActive={state === STATE.UNIVERSITY} - > - setErrors(er => ({ ...er, graduation_year: err }))} - onChange={(v) => setProfileData(d => ({ ...d, graduation_year: v }))} - /> - -
- )} - - {state >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( - setState(STATE.COMPETED_BEFORE)} - onNext={() => { - !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(STATE.COMPETED_BEFORE) - }} - isActive={state === STATE.EMPLOYER} - > - { - setProfileData(d => ({...d, employer: v})) - setErrors(er => ({...er, employer: undefined})) - }} - /> - - )} - - {state >= STATE.COMPETED_BEFORE && ( - { - setState(STATE.COMPETED_BEFORE + 2) - }} - isActive={state === STATE.COMPETED_BEFORE} - > - { - setProfileData(d => ({ ...d, has_competition_experience: val })) - if (state >= STATE.COMPETED_BEFORE + 2) return - setState(val ? STATE.COMPETITION_EXP : STATE.COMPETED_BEFORE + 2) - }} - /> - - )} - - {state >= STATE.COMPETITION_EXP && profileData.has_competition_experience && ( - { - setProfileData(d => ({ ...d, has_competition_experience: undefined })) - setCompetitionRows([]) - setState(STATE.VOLUNTEERED_BEFORE) - }} - onNext={() => { - if (competitionRows.length === 0 || !competitionRows.every(isCompetitionRowValid)) { - setErrors(er => ({ ...er, competition_exp: "Each entry needs a school and a matched event." })) - return - } - setState(STATE.VOLUNTEERED_BEFORE) - }} - isActive={state === STATE.COMPETITION_EXP} - > - - {errors.competition_exp && ( -

- {errors.competition_exp} -

- )} -
- )} - - {state >= STATE.VOLUNTEERED_BEFORE && ( - { - setState(STATE.SHIRT_SIZE) - }} - isActive={state === STATE.VOLUNTEERED_BEFORE} - > - { - setProfileData(d => ({ ...d, has_volunteer_experience: val })) - if (state >= STATE.SHIRT_SIZE) return - setState(val ? STATE.VOLUNTEERING_EXP : STATE.SHIRT_SIZE) - }} - /> - - )} - - {state >= STATE.VOLUNTEERING_EXP && profileData.has_volunteer_experience && ( - { - setProfileData(d => ({ ...d, has_volunteer_experience: undefined })) - setVolunteerRows([]) - setState(STATE.SHIRT_SIZE) - }} - onNext={() => { - if (volunteerRows.length === 0 || !volunteerRows.every(isVolunteerRowValid)) { - setErrors(er => ({ ...er, volunteering_exp: "Each entry needs a tournament name, a 4-digit year, and a role." })) - return - } - setState(STATE.SHIRT_SIZE) - }} - isActive={state === STATE.VOLUNTEERING_EXP} - > - - {errors.volunteering_exp && ( -

- {errors.volunteering_exp} -

- )} -
- )} - - {state >= STATE.SHIRT_SIZE && ( - { - setProfileData(d => ({ ...d, shirt_size: undefined })) - setState(STATE.DIETARY_RESTRICTIONS) - }} - isActive={state === STATE.SHIRT_SIZE} - > - { - setProfileData(d => ({ ...d, shirt_size: v })) - if (state === STATE.SHIRT_SIZE) setState(STATE.DIETARY_RESTRICTIONS) - }} - /> - - )} - - {state >= STATE.DIETARY_RESTRICTIONS && ( - { - setState(STATE.COMPLETE) - }} - isActive={state === STATE.DIETARY_RESTRICTIONS} - > - { - setHasDietary(val) - if (state >= STATE.COMPLETE) return - setState(val ? STATE.DIETARY_TEXT : STATE.COMPLETE) - }} - /> - - )} - - {state >= STATE.DIETARY_TEXT && hasDietary && ( - { - setHasDietary(null) - setState(STATE.COMPLETE) - }} - onNext={() => { - !profileData.dietary_restriction ? setErrors(er => ({...er, dietary_restriction: "Cannot be empty."})) - : setState(STATE.COMPLETE) - }} - isActive={state === STATE.DIETARY_TEXT} - > - { - setProfileData(d => ({...d, dietary_restriction: v})) - setErrors(er => ({...er, dietary_restriction: undefined})) - }} - /> - - )} - -
- - -
- -
- {errors.form2 && ( -

- {errors.form2} -

- )} -
-
-
-
- )} - +
+ {showVerifyModal && } + +

Sign Up

+ + + +
+ + Already have an account?{' '} + + +
+
) -} \ No newline at end of file +} 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/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx new file mode 100644 index 00000000..9218c7c8 --- /dev/null +++ b/frontend/app/onboarding/page.tsx @@ -0,0 +1,633 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { + usersApi, canonicalEventsApi, CanonicalEvent, + STUDENT_STATUS, SHIRT_SIZE, 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 { Spinner } from "@/components/ui/Spinner"; +import { ProfileCard } from "@/components/profile/ProfileCard"; +import { ProfileQuestion } from "@/components/profile/ProfileQuestion"; +import { + PronounsField, StudentStatusField, + UniversityField, MajorField, YearLevelField, GraduationYearField, + EmployerField, YesNoField, ShirtSizeField, DietaryRestrictionField, +} from "@/components/profile/ProfileFields"; +import { + CompetitionExperienceSpreadsheet, CompetitionExperienceDraft, isCompetitionRowValid, + competitionExperienceToDraft, + VolunteerExperienceSpreadsheet, VolunteerExperienceDraft, isVolunteerRowValid, + volunteerExperienceToDraft, +} from "@/components/profile/ExperienceTables"; + +const STATE = { + NAME: 1, + PHONE: 2, + DATE_OF_BIRTH: 3, + PRONOUNS: 4, + STUDENT_STATUS: 5, + UNIVERSITY: 6, + EMPLOYER: 7, + COMPETED_BEFORE: 8, + COMPETITION_EXP: 9, + VOLUNTEERED_BEFORE: 10, + VOLUNTEERING_EXP: 11, + SHIRT_SIZE: 12, + DIETARY_RESTRICTIONS: 13, + DIETARY_TEXT: 14, + COMPLETE: 15, +} as const; + +interface ProfileDraft { + date_of_birth?: string; + pronouns?: string; + student_status?: STUDENT_STATUS; + university?: string; + major?: string; + year_level?: number; + graduation_year?: number; + employer?: string; + has_competition_experience?: boolean; + has_volunteer_experience?: boolean; + shirt_size?: SHIRT_SIZE; + dietary_restriction?: string; +} + +export default function OnboardingPage() { + const { user: currentUser, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [original, setOriginal] = useState(null); + const [loadError, setLoadError] = useState(null); + + const [state, setState] = useState(STATE.NAME); + const [loading, setLoading] = useState(false); + + const [name, setName] = useState<{ first: string; last: string }>({ first: "", last: "" }); + const [phone, setPhone] = useState(""); + const handlePhoneChange = useFormattedInputChange( + phone, setPhone, formatPhone, (v) => v.replace(/\D/g, "").slice(0, 10), + ); + + const [profileData, setProfileData] = useState({}); + const [events, setEvents] = useState([]); + const [competitionRows, setCompetitionRows] = useState([]); + const [volunteerRows, setVolunteerRows] = useState([]); + const [hasDietary, setHasDietary] = useState(null); + + const [errors, setErrors] = useState>({}); + + useEffect(() => { + if (authLoading) return; + + if (!currentUser) { + router.replace("/"); + return; + } + + if (currentUser.is_onboarding_complete) { + router.replace("/dashboard"); + return; + } + + usersApi.meFull() + .then((user) => { + setOriginal(user); + setName({ first: user.first_name ?? "", last: user.last_name ?? "" }); + setPhone(user.phone ?? ""); + setProfileData({ + date_of_birth: user.date_of_birth ?? undefined, + pronouns: user.pronouns ?? undefined, + student_status: user.student_status ?? undefined, + university: user.university ?? undefined, + major: user.major ?? undefined, + year_level: user.year_level ?? undefined, + graduation_year: user.graduation_year ?? undefined, + employer: user.employer ?? undefined, + has_competition_experience: user.has_competition_experience ?? undefined, + has_volunteer_experience: user.has_volunteer_experience ?? undefined, + shirt_size: user.shirt_size ?? undefined, + dietary_restriction: user.dietary_restriction ?? undefined, + }); + setHasDietary(user.dietary_restriction !== null ? true : null); + setCompetitionRows(user.competition_experience.map(competitionExperienceToDraft)); + setVolunteerRows(user.volunteer_experience.map(volunteerExperienceToDraft)); + }) + .catch(() => setLoadError("Failed to load your account.")); + + canonicalEventsApi.list().then(setEvents).catch(() => {}); + }, [authLoading, currentUser, router]); + + async function handleSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setLoading(true); + setErrors({}); + + const cleaned: Record = { + first_name: name.first, + last_name: name.last, + phone, + ...profileData, + }; + + if (cleaned.student_status === "Undergraduate" || cleaned.student_status === "Graduate") { + cleaned.employer = undefined; + } else if (cleaned.student_status === "Non-Student") { + cleaned.university = cleaned.major = cleaned.year_level = cleaned.graduation_year = undefined; + } else { + cleaned.student_status = cleaned.employer = cleaned.university = cleaned.major = cleaned.year_level = cleaned.graduation_year = undefined; + } + + for (const key of Object.keys(cleaned)) { + if (cleaned[key] === "") cleaned[key] = undefined; + } + + try { + await usersApi.updateMe(cleaned); + + if (profileData.has_competition_experience) { + await Promise.all(competitionRows.map((row) => + usersApi.addCompetitionExperience({ + event_id: row.event_id as number, + school: row.school, + notes: row.notes || null, + }) + )); + } + + if (profileData.has_volunteer_experience) { + await Promise.all(volunteerRows.map((row) => + usersApi.addVolunteerExperience({ + tournament_name: row.tournament_name, + year: Number(row.year), + role: row.role, + event_id: row.event_id ?? undefined, + notes: (row.event_id === null && row.event_name.trim()) || row.notes_other.trim() + ? { + ...(row.event_id === null && row.event_name.trim() ? { event: row.event_name.trim() } : {}), + ...(row.notes_other.trim() ? { other: row.notes_other.trim() } : {}), + } + : undefined, + }) + )); + } + + window.location.href = "/dashboard"; + } catch (error: unknown) { + setErrors({ form: error instanceof ApiError ? error.message : "Something went wrong. Try again." }); + } finally { + setLoading(false); + } + } + + if (authLoading || (!original && !loadError)) { + return ( +
+ +
+ ); + } + + if (loadError || !original) { + return ( +

+ {loadError ?? "Something went wrong."} +

+ ); + } + + return ( +
+
+
+
+ NEXUS +
+

+ Complete Your Profile +

+
+ +
+ + { + const ers: Record = {}; + if (!name.first.trim()) ers.first_name = "Cannot be empty."; + if (!name.last.trim()) ers.last_name = "Cannot be empty."; + if (Object.keys(ers).length > 0) { + setErrors((er) => ({ ...er, ...ers })); + return; + } + setState(STATE.PHONE); + }} + isActive={state === STATE.NAME} + > +
+ { setName((n) => ({ ...n, first: e.target.value })); setErrors((er) => ({ ...er, first_name: undefined })); }} + autoComplete="given-name" + error={errors.first_name} + fullWidth + /> + { setName((n) => ({ ...n, last: e.target.value })); setErrors((er) => ({ ...er, last_name: undefined })); }} + autoComplete="family-name" + error={errors.last_name} + fullWidth + /> +
+
+
+ + {state >= STATE.PHONE && ( + + { + const err = validatePhone(phone); + if (err) { + setErrors((er) => ({ ...er, phone: err })); + return; + } + setState(STATE.DATE_OF_BIRTH); + }} + isActive={state === STATE.PHONE} + > + { handlePhoneChange(e); setErrors((er) => ({ ...er, phone: undefined })); }} + autoComplete="tel" + error={errors.phone} + fullWidth + /> + + + )} + + {state >= STATE.DATE_OF_BIRTH && ( + + { + const err = validateDateOfBirth(profileData.date_of_birth ?? ""); + if (err) { + setErrors((er) => ({ ...er, date_of_birth: err })); + return; + } + setState(STATE.PRONOUNS); + }} + isActive={state === STATE.DATE_OF_BIRTH} + > + { + setProfileData((d) => ({ ...d, date_of_birth: e.target.value })); + setErrors((er) => ({ ...er, date_of_birth: undefined })); + }} + error={errors.date_of_birth} + fullWidth + /> + + + )} + + {state >= STATE.PRONOUNS && ( + + setState(STATE.STUDENT_STATUS)} + onNext={() => { + if (!profileData.pronouns?.trim()) { + setErrors((er) => ({ ...er, pronouns: "Cannot be empty." })); + return; + } + setState(STATE.STUDENT_STATUS); + }} + isActive={state === STATE.PRONOUNS} + > + { + setProfileData((d) => ({ ...d, pronouns: v })); + setErrors((er) => ({ ...er, pronouns: undefined })); + }} + /> + + + {state >= STATE.STUDENT_STATUS && ( + setState(STATE.STUDENT_STATUS + 3)} + isActive={state === STATE.STUDENT_STATUS} + > + { + setProfileData((d) => ({ ...d, student_status: v })); + if (state >= STATE.STUDENT_STATUS + 3) return; + if (v === "Undergraduate" || v === "Graduate") { + setState(STATE.UNIVERSITY); + } else if (v === "Non-Student") { + setState(STATE.EMPLOYER); + } + }} + /> + + )} + + {state >= STATE.UNIVERSITY && (profileData.student_status === "Undergraduate" || profileData.student_status === "Graduate") && ( +
+ + { + setProfileData((d) => ({ ...d, university: v })); + setErrors((er) => ({ ...er, university: undefined })); + }} + /> + + + { + setProfileData((d) => ({ ...d, major: v })); + setErrors((er) => ({ ...er, major: undefined })); + }} + /> + + + { + setProfileData((d) => ({ ...d, year_level: v })); + setErrors((er) => ({ ...er, year_level: undefined })); + }} + /> + + { + setProfileData((d) => ({ ...d, university: undefined, major: undefined, year_level: undefined, graduation_year: undefined })); + setState(STATE.UNIVERSITY + 2); + }} + onNext={() => { + const ers: typeof errors = {}; + + if (!profileData.university) ers.university = "Cannot be empty."; + if (!profileData.major) ers.major = "Cannot be empty."; + if (!profileData.year_level) ers.year_level = "Cannot be empty."; + + if (!profileData.graduation_year) ers.graduation_year = "Cannot be empty."; + else if (profileData.graduation_year < 1000 || profileData.graduation_year > 9999) + ers.graduation_year = "Must be a valid year."; + + Object.keys(ers).length > 0 ? setErrors((er) => ({ ...er, ...ers })) : setState(STATE.UNIVERSITY + 2); + }} + isActive={state === STATE.UNIVERSITY} + > + setErrors((er) => ({ ...er, graduation_year: err }))} + onChange={(v) => setProfileData((d) => ({ ...d, graduation_year: v }))} + /> + +
+ )} + + {state >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( + setState(STATE.COMPETED_BEFORE)} + onNext={() => { + !profileData.employer ? setErrors((er) => ({ ...er, employer: "Cannot be empty." })) : setState(STATE.COMPETED_BEFORE); + }} + isActive={state === STATE.EMPLOYER} + > + { + setProfileData((d) => ({ ...d, employer: v })); + setErrors((er) => ({ ...er, employer: undefined })); + }} + /> + + )} +
+ )} + + {state >= STATE.COMPETED_BEFORE && ( + + setState(STATE.COMPETED_BEFORE + 2)} + isActive={state === STATE.COMPETED_BEFORE} + > + { + setProfileData((d) => ({ ...d, has_competition_experience: val })); + if (state >= STATE.COMPETED_BEFORE + 2) return; + setState(val ? STATE.COMPETITION_EXP : STATE.COMPETED_BEFORE + 2); + }} + /> + + + {state >= STATE.COMPETITION_EXP && profileData.has_competition_experience && ( + { + setProfileData((d) => ({ ...d, has_competition_experience: undefined })); + setCompetitionRows([]); + setState(STATE.VOLUNTEERED_BEFORE); + }} + onNext={() => { + if (competitionRows.length === 0 || !competitionRows.every(isCompetitionRowValid)) { + setErrors((er) => ({ ...er, competition_exp: "Each entry needs a school and a matched event." })); + return; + } + setState(STATE.VOLUNTEERED_BEFORE); + }} + isActive={state === STATE.COMPETITION_EXP} + > + + {errors.competition_exp && ( +

+ {errors.competition_exp} +

+ )} +
+ )} +
+ )} + + {state >= STATE.VOLUNTEERED_BEFORE && ( + + setState(STATE.SHIRT_SIZE)} + isActive={state === STATE.VOLUNTEERED_BEFORE} + > + { + setProfileData((d) => ({ ...d, has_volunteer_experience: val })); + if (state >= STATE.SHIRT_SIZE) return; + setState(val ? STATE.VOLUNTEERING_EXP : STATE.SHIRT_SIZE); + }} + /> + + + {state >= STATE.VOLUNTEERING_EXP && profileData.has_volunteer_experience && ( + { + setProfileData((d) => ({ ...d, has_volunteer_experience: undefined })); + setVolunteerRows([]); + setState(STATE.SHIRT_SIZE); + }} + onNext={() => { + if (volunteerRows.length === 0 || !volunteerRows.every(isVolunteerRowValid)) { + setErrors((er) => ({ ...er, volunteering_exp: "Each entry needs a tournament name, a 4-digit year, and a role." })); + return; + } + setState(STATE.SHIRT_SIZE); + }} + isActive={state === STATE.VOLUNTEERING_EXP} + > + + {errors.volunteering_exp && ( +

+ {errors.volunteering_exp} +

+ )} +
+ )} +
+ )} + + {state >= STATE.SHIRT_SIZE && ( + + { + setProfileData((d) => ({ ...d, shirt_size: undefined })); + setState(STATE.DIETARY_RESTRICTIONS); + }} + isActive={state === STATE.SHIRT_SIZE} + > + { + setProfileData((d) => ({ ...d, shirt_size: v })); + if (state === STATE.SHIRT_SIZE) setState(STATE.DIETARY_RESTRICTIONS); + }} + /> + + + {state >= STATE.DIETARY_RESTRICTIONS && ( + setState(STATE.COMPLETE)} + isActive={state === STATE.DIETARY_RESTRICTIONS} + > + { + setHasDietary(val); + if (state >= STATE.COMPLETE) return; + setState(val ? STATE.DIETARY_TEXT : STATE.COMPLETE); + }} + /> + + )} + + {state >= STATE.DIETARY_TEXT && hasDietary && ( + { + setHasDietary(null); + setState(STATE.COMPLETE); + }} + onNext={() => { + !profileData.dietary_restriction ? setErrors((er) => ({ ...er, dietary_restriction: "Cannot be empty." })) + : setState(STATE.COMPLETE); + }} + isActive={state === STATE.DIETARY_TEXT} + > + { + setProfileData((d) => ({ ...d, dietary_restriction: v })); + setErrors((er) => ({ ...er, dietary_restriction: undefined })); + }} + /> + + )} + + )} + +
+ +
+ +
+ {errors.form && ( +

+ {errors.form} +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index f4db8ac0..003d7246 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,47 +1,23 @@ 'use client' -import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/Button' -import { Input } from '@/components/ui/Input' -import { IconArrowDown } from '@/components/ui/Icons' -import { ApiError } from '@/lib/api' +import { GridWarp } from '@/components/home/GridWarp' export default function HomePage() { - const [loginVisible, setLoginVisible] = useState(false) - - useEffect(() => { - const onScroll = () => { - setLoginVisible(window.scrollY > window.innerHeight * 0.3) - } - window.addEventListener('scroll', onScroll, { passive: true }) - return () => window.removeEventListener('scroll', onScroll) - }, []) + const router = useRouter() return (
- - {/* ---------------------------------------------------------------- - Hero — full viewport - ---------------------------------------------------------------- */}
+
-
@@ -49,6 +25,7 @@ export default function HomePage() {

+ {/* Floating island nav */}
- -
-
- - {/* ---------------------------------------------------------------- - Login section - ---------------------------------------------------------------- */} -
-
-
-

- Sign in to your
dashboard -

+
+ +
- -
@@ -124,98 +82,7 @@ export default function HomePage() { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } - @keyframes arrow-bounce { - 0%, 100% { transform: translateY(0); opacity: 0.4; } - 50% { transform: translateY(6px); opacity: 1; } - } - input::placeholder { color: var(--color-text-tertiary); } `}
) } - -function LoginForm() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) - const [errors, setErrors] = useState<{ email?: string; password?: string; form?: string }>({}) - - function validate() { - const e: typeof errors = {} - if (!email.trim()) e.email = 'Email is required' - else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = 'Enter a valid email' - if (!password.trim()) e.password = 'Password is required' - return e - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const validation = validate() - if (Object.keys(validation).length > 0) { - setErrors(validation) - return - } - setErrors({}) - setLoading(true) - try { - const { authApi } = await import('@/lib/api') - await authApi.login(email, password) - window.location.href = '/dashboard' - } catch (err: unknown) { - if (err instanceof ApiError && err.status === 401) { - setErrors({ form: 'Invalid email or password' }) - } else { - setErrors({ form: 'Something went wrong — please try again' }) - } - } finally { - setLoading(false) - } - } - - return ( -
- { setEmail(e.target.value); setErrors(ev => ({ ...ev, email: undefined })) }} - autoComplete="email" - error={errors.email} - fullWidth - /> - { setPassword(e.target.value); setErrors(ev => ({ ...ev, password: undefined })) }} - autoComplete="current-password" - error={errors.password} - fullWidth - /> - -
- {errors.form && ( -

- {errors.form} -

- )} -
- - -
- ) -} \ No newline at end of file diff --git a/frontend/app/profile/[id]/edit/page.tsx b/frontend/app/profile/[id]/edit/page.tsx index 43bef927..62daf745 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 @@ -320,9 +279,7 @@ export default function ProfileEditPage() { maxWidth: "900px", margin: "0 auto", padding: "32px 20px", display: "flex", flexDirection: "column", gap: "20px", }}> - - - + diff --git a/frontend/app/settings/account/page.tsx b/frontend/app/settings/account/page.tsx new file mode 100644 index 00000000..ff8e4815 --- /dev/null +++ b/frontend/app/settings/account/page.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { usersApi, authApi, UserMeFull, EmailPendingChange, ApiError } from "@/lib/api"; +import { Banner } from "@/components/ui/Banner"; +import { Tooltip } from "@/components/ui/Tooltip"; +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 { Spinner } from "@/components/ui/Spinner"; +import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; +import { SettingsRow, SettingsSection } from "@/components/settings/SettingsRow"; +import { SettingsPageHeading } from "@/components/settings/SettingsPageHeading"; +import { ChangeEmailModal } from "@/components/settings/ChangeEmailModal"; +import { DeactivateAccountModal } from "@/components/settings/DeactivateAccountModal"; +import { DeleteAccountModal } from "@/components/settings/DeleteAccountModal"; + +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); + + const [showEmailModal, setShowEmailModal] = useState(false); + const [pendingEmailChange, setPendingEmailChange] = useState(null); + + const [showDeactivateModal, setShowDeactivateModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const [resending, setResending] = useState(false); + const [resendError, setResendError] = useState(undefined); + const [resendSuccess, setResendSuccess] = useState(false); + + const [emailChangeResendError, setEmailChangeResendError] = useState(undefined); + const [emailChangeResending, setEmailChangeResending] = useState(false); + const [now, setNow] = useState(Date.now()); + + 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.")); + + authApi.getPendingEmailChange() + .then(setPendingEmailChange) + .catch(() => {}); + }, [authLoading, currentUser, router]); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + 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 handleResendEmailChange() { + if (!pendingEmailChange?.new_email) return; + setEmailChangeResendError(undefined); + setEmailChangeResending(true); + try { + const result = await authApi.requestEmailChange(pendingEmailChange.new_email); + setPendingEmailChange(result); + } catch (error: unknown) { + setEmailChangeResendError(error instanceof ApiError ? error.message : "Failed to resend confirmation email."); + } finally { + setEmailChangeResending(false); + } + } + + async function handleResendVerification() { + setResendError(undefined); + setResendSuccess(false); + setResending(true); + try { + await authApi.sendEmailVerification(); + setResendSuccess(true); + } catch (error: unknown) { + setResendError(error instanceof ApiError ? error.message : "Failed to send verification email."); + } finally { + setResending(false); + } + } + + if (authLoading || (!original && !loadError)) { + return ( +
+ +
+ ); + } + + if (loadError || !original || !draft) { + return ( +

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

+ ); + } + + return ( +
+ + + + + setDraft((d) => (d ? { ...d, first_name: e.target.value } : d))} + /> + + + setDraft((d) => (d ? { ...d, last_name: e.target.value } : d))} + /> + + +
+
+ + {original.email} + + +
+ + {pendingEmailChange?.new_email ? ( + + + + } + /> + ) : !original.email_verified && ( + + + + ) + } + /> + )} +
+
+ + { + 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} + /> + +
+ + + +
+ +
+
+ +
+ +
+
+
+ + {showEmailModal && ( + setShowEmailModal(false)} + /> + )} + + {showDeactivateModal && setShowDeactivateModal(false)} />} + {showDeleteModal && setShowDeleteModal(false)} />} + + +
+ ); +} diff --git a/frontend/app/settings/constants.ts b/frontend/app/settings/constants.ts new file mode 100644 index 00000000..51351d56 --- /dev/null +++ b/frontend/app/settings/constants.ts @@ -0,0 +1,2 @@ +export const SETTINGS_NAV_WIDTH = 220; +export const SETTINGS_CONTAINER_MAX_WIDTH = 1000; diff --git a/frontend/app/settings/layout.tsx b/frontend/app/settings/layout.tsx new file mode 100644 index 00000000..84258fca --- /dev/null +++ b/frontend/app/settings/layout.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Topbar } from "@/components/layout/Topbar"; +import { SettingsNav } from "@/components/settings/SettingsNav"; +import { useIsMobile } from "@/lib/useIsMobile"; +import { SETTINGS_NAV_WIDTH, SETTINGS_CONTAINER_MAX_WIDTH } from "@/app/settings/constants"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + const isMobile = useIsMobile(); + + return ( +
+ +
+ +
+ {children} +
+
+
+ ); +} diff --git a/frontend/app/settings/security/page.tsx b/frontend/app/settings/security/page.tsx new file mode 100644 index 00000000..4d7b512f --- /dev/null +++ b/frontend/app/settings/security/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { authApi, ApiError } from "@/lib/api"; +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, SettingsSection } from "@/components/settings/SettingsRow"; +import { SettingsPageHeading } from "@/components/settings/SettingsPageHeading"; +import { PasswordChecklist } from "@/components/auth/PasswordChecklist"; +import { SessionList } from "@/components/settings/SessionList"; + +const EMPTY_CHECKS: PasswordChecks = { + length: false, upper: false, lower: false, number: false, symbol: false, confirm: false, +}; + +export default function SecuritySettingsPage() { + const { user: currentUser, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [checks, setChecks] = useState(EMPTY_CHECKS); + + const [errors, setErrors] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (authLoading) return; + if (!currentUser) router.replace("/"); + }, [authLoading, currentUser, router]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSuccess(false); + setErrors({}); + + if (!currentPassword) { + setErrors((er) => ({ ...er, current_password: "Cannot be empty." })); + return; + } + + const passwordErr = validatePassword(newPassword); + if (passwordErr) { + setErrors((er) => ({ ...er, new_password: passwordErr })); + return; + } + if (newPassword !== confirmPassword) { + setErrors((er) => ({ ...er, confirm_password: "Passwords don't match." })); + return; + } + + setSubmitting(true); + try { + await authApi.changePassword(currentPassword, newPassword); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setChecks(EMPTY_CHECKS); + setSuccess(true); + } catch (error: unknown) { + if (error instanceof ApiError && error.status === 401) { + setErrors((er) => ({ ...er, current_password: error.message })); + } else { + setErrors((er) => ({ ...er, form: "Something went wrong. Try again." })); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + +
+ + { + 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} + /> + + + {newPassword && ( +
+ +
+ )} + + {success && ( +
+ +
+ )} + {errors.form && ( +
+ +
+ )} + + +
+
+ + + + +
+ ); +} diff --git a/frontend/components/auth/CredentialsForm.tsx b/frontend/components/auth/CredentialsForm.tsx new file mode 100644 index 00000000..d010a260 --- /dev/null +++ b/frontend/components/auth/CredentialsForm.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useState } from "react" +import { checkPassword, validateEmail, validatePassword, PasswordChecks } from "@/lib/auth" +import { Input } from "@/components/ui/Input" +import { Button } from "@/components/ui/Button" +import { PasswordChecklist } from "@/components/auth/PasswordChecklist" + +const EMPTY_CHECKS: PasswordChecks = { + length: false, upper: false, lower: false, number: false, symbol: false, confirm: false, +} + +interface CredentialsFormProps { + // Omit both to render a password-only form (e.g. account-setup, where the + // invited email isn't known client-side until after the token is submitted). + email?: string + onEmailChange?: (value: string) => void + onSubmit: (password: string) => void | Promise + submitLabel: string + loading?: boolean + formError?: string +} + +// Shared email+password credentials form — sign-up's phase-1 fields, +// reused as-is by account-setup (password only, no email prop passed). +export function CredentialsForm({ + email, onEmailChange, onSubmit, submitLabel, loading = false, formError, +}: CredentialsFormProps) { + const showEmail = email !== undefined && !!onEmailChange + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [checks, setChecks] = useState(EMPTY_CHECKS) + const [errors, setErrors] = useState<{ email?: string; password?: string; confirm_password?: string }>({}) + + async function handleSubmit(e: React.SyntheticEvent) { + e.preventDefault() + setErrors({}) + + const nextErrors = { + email: showEmail ? (validateEmail(email!) ?? undefined) : undefined, + password: validatePassword(password) ?? undefined, + confirm_password: password !== confirmPassword ? "Passwords don't match." : undefined, + } + + if (Object.values(nextErrors).some(Boolean)) { + setErrors(nextErrors) + return + } + + await onSubmit(password) + } + + return ( +
+ {showEmail && ( + { onEmailChange!(e.target.value); setErrors(er => ({ ...er, email: undefined })) }} + autoComplete="email" + error={errors.email} + fullWidth + /> + )} + { + setPassword(e.target.value) + setChecks(checkPassword(e.target.value, confirmPassword)) + setErrors(er => ({ ...er, password: undefined })) + }} + autoComplete="new-password" + error={errors.password} + fullWidth + /> + + {password && ( + <> + { + setConfirmPassword(e.target.value) + setChecks(checkPassword(password, e.target.value)) + setErrors(er => ({ ...er, confirm_password: undefined })) + }} + autoComplete="new-password" + error={errors.confirm_password} + fullWidth + /> + + + )} + + + +
+ {formError && ( +

+ {formError} +

+ )} +
+ + ) +} diff --git a/frontend/components/auth/PasswordChecklist.tsx b/frontend/components/auth/PasswordChecklist.tsx new file mode 100644 index 00000000..5cff7c7b --- /dev/null +++ b/frontend/components/auth/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} +
+ ))} +
+ ); +} diff --git a/frontend/components/home/GridWarp.tsx b/frontend/components/home/GridWarp.tsx new file mode 100644 index 00000000..3ce7cee4 --- /dev/null +++ b/frontend/components/home/GridWarp.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useEffect, useRef } from 'react' +import * as THREE from 'three' + +const CELL = 48 // grid cell size, px +const SEGMENT = 16 // px per line segment — smaller bends more smoothly +const INFLUENCE_RADIUS = 260 // px — how far the "gravity" reaches +const EASE = 0.07 // cursor-follow smoothing, 0-1 + +// At the cursor itself, points are radially compressed to this fraction of +// their original distance from it. 0 means points directly at the cursor +// converge onto that exact pixel (a true point). Scales smoothly back up +// to 1 (no change) at INFLUENCE_RADIUS. +const SHRINK_MIN = 0.5 + +function smoothstep(t: number): number { + return t * t * (3 - 2 * t) +} + +const LINE_COLOR = 0xc8c8c2 // matches --color-border-strong + +// Full-viewport WebGL grid that warps toward the cursor like a gravity well — +// grid points near the cursor get pulled toward it with inverse-linear falloff. +export function GridWarp() { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + let width = window.innerWidth + let height = window.innerHeight + + const scene = new THREE.Scene() + const camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, 0.1, 10) + camera.position.z = 5 + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + renderer.setSize(width, height) + renderer.domElement.style.display = 'block' + container.appendChild(renderer.domElement) + + let restArray = new Float32Array(0) + let geometry = new THREE.BufferGeometry() + let lines: THREE.LineSegments | null = null + + function buildGrid(w: number, h: number) { + if (lines) { + scene.remove(lines) + geometry.dispose() + } + + const positions: number[] = [] + const startX = -w / 2 - CELL + const startY = -h / 2 - CELL + const cols = Math.ceil((w + 2 * CELL) / CELL) + const rows = Math.ceil((h + 2 * CELL) / CELL) + + // vertical lines + for (let c = 0; c <= cols; c++) { + const x = startX + c * CELL + const segCount = Math.ceil((h + 2 * CELL) / SEGMENT) + for (let s = 0; s < segCount; s++) { + const y0 = startY + s * SEGMENT + const y1 = startY + (s + 1) * SEGMENT + positions.push(x, y0, 0, x, y1, 0) + } + } + // horizontal lines + for (let r = 0; r <= rows; r++) { + const y = startY + r * CELL + const segCount = Math.ceil((w + 2 * CELL) / SEGMENT) + for (let s = 0; s < segCount; s++) { + const x0 = startX + s * SEGMENT + const x1 = startX + (s + 1) * SEGMENT + positions.push(x0, y, 0, x1, y, 0) + } + } + + const posArray = new Float32Array(positions) + restArray = posArray.slice() + + geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(posArray, 3)) + + const material = new THREE.LineBasicMaterial({ color: LINE_COLOR, transparent: true, opacity: 0.9 }) + lines = new THREE.LineSegments(geometry, material) + scene.add(lines) + } + + buildGrid(width, height) + + const mouse = new THREE.Vector2(0, 0) + const displayMouse = new THREE.Vector2(0, 0) + let hasMoved = false + + function onMouseMove(e: MouseEvent) { + mouse.x = e.clientX - width / 2 + mouse.y = -(e.clientY - height / 2) + if (!hasMoved) { + displayMouse.copy(mouse) + hasMoved = true + } + } + window.addEventListener('mousemove', onMouseMove, { passive: true }) + + let frameId: number + function animate() { + displayMouse.lerp(mouse, EASE) + + const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute + const arr = posAttr.array as Float32Array + + for (let i = 0; i < arr.length; i += 3) { + const rx = restArray[i] + const ry = restArray[i + 1] + const dx = rx - displayMouse.x + const dy = ry - displayMouse.y + const dist = Math.sqrt(dx * dx + dy * dy) + + if (hasMoved && dist < INFLUENCE_RADIUS) { + // Scale each point's offset from the cursor (not translate toward + // it) — this preserves both angle and radial ordering exactly, so + // no two points can ever swap sides or cross paths. A translate- + // by-fixed-amount approach let points fold across each other right + // at the cursor (a visible X/knot artifact); a pure radial scale + // can't fold since scaled distance = dist * scale(dist) is + // strictly increasing in dist. + const scale = SHRINK_MIN + (1 - SHRINK_MIN) * smoothstep(dist / INFLUENCE_RADIUS) + arr[i] = displayMouse.x + dx * scale + arr[i + 1] = displayMouse.y + dy * scale + } else { + arr[i] = rx + arr[i + 1] = ry + } + } + posAttr.needsUpdate = true + + renderer.render(scene, camera) + frameId = requestAnimationFrame(animate) + } + animate() + + function onResize() { + width = window.innerWidth + height = window.innerHeight + camera.left = -width / 2 + camera.right = width / 2 + camera.top = height / 2 + camera.bottom = -height / 2 + camera.updateProjectionMatrix() + renderer.setSize(width, height) + buildGrid(width, height) + } + window.addEventListener('resize', onResize) + + return () => { + cancelAnimationFrame(frameId) + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('resize', onResize) + renderer.dispose() + geometry.dispose() + container.removeChild(renderer.domElement) + } + }, []) + + return
+} diff --git a/frontend/components/layout/Topbar.tsx b/frontend/components/layout/Topbar.tsx index d1e17d4c..120a24f5 100644 --- a/frontend/components/layout/Topbar.tsx +++ b/frontend/components/layout/Topbar.tsx @@ -15,6 +15,9 @@ interface TopbarProps { showAvatar?: boolean; tournamentId?: string | number; sidebarExpanded?: boolean; + // Extra left padding to clear a fixed-position control (e.g. a mobile + // drawer toggle) rendered outside the Topbar but overlapping its top-left. + extraLeftPad?: number; } // ─── Tournament Dropdown ────────────────────────────────────────────────────── @@ -141,8 +144,9 @@ export function Topbar({ showAvatar = true, tournamentId, sidebarExpanded = false, + extraLeftPad = 0, }: TopbarProps) { - const leftPad = (sidebarExpanded ? EXPANDED_W - COLLAPSED_W : 0) + 16; + const leftPad = (sidebarExpanded ? EXPANDED_W - COLLAPSED_W : 0) + 16 + extraLeftPad; return (
{children}
diff --git a/frontend/components/profile/ProfileQuestion.tsx b/frontend/components/profile/ProfileQuestion.tsx index 3d33992f..6d3fd427 100644 --- a/frontend/components/profile/ProfileQuestion.tsx +++ b/frontend/components/profile/ProfileQuestion.tsx @@ -24,10 +24,6 @@ export function ProfileQuestion({ return (