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"""\
+
+
+ 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 ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
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.
+
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
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
-
-
+
+
+
+ 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
-
-
-
-
- )}
-
- {state >= STATE.DATE_OF_BIRTH && (
-
- {showVerifyModal && }
-
-
-
NEXUS
-
-
-
-
Complete Your Profile
-
-
-
-
- )}
- >
+
+ {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
+
+
+
+
+
+
+ );
+}
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 (
-
- )
-}
\ 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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ )
+}
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 (
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 (