diff --git a/.env.example b/.env.example index bfa0ceb..bcf3e6a 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,12 @@ DEBUG=true DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke +# Supabase Auth (backend) +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=your-anon-key +SUPABASE_JWT_SECRET=your-jwt-secret + # Frontend settings (used by Vite) VITE_API_URL=http://localhost:8000 +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key diff --git a/backend/.env.example b/backend/.env.example index ea90daf..a91efe4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,3 +7,8 @@ API_V1_PREFIX="/api/v1" # Database settings DATABASE_URL="sqlite:///./nuzlocke.db" + +# Supabase Auth +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=your-anon-key +SUPABASE_JWT_SECRET=your-jwt-secret diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d09a337..0ed6ad5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "sqlalchemy[asyncio]==2.0.48", "asyncpg==0.31.0", "alembic==1.18.4", + "PyJWT==2.10.1", ] [project.optional-dependencies] diff --git a/backend/scripts/assign_unowned_runs.py b/backend/scripts/assign_unowned_runs.py new file mode 100644 index 0000000..753d4f1 --- /dev/null +++ b/backend/scripts/assign_unowned_runs.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Assign existing unowned runs to a user. + +Usage: + cd backend && uv run python scripts/assign_unowned_runs.py + +This script assigns all runs without an owner to the specified user. +Useful for migrating existing data after implementing user ownership. +""" + +import asyncio +import sys +from uuid import UUID + +from sqlalchemy import select, update + +sys.path.insert(0, "src") + +from app.core.database import async_session # noqa: E402 +from app.models.nuzlocke_run import NuzlockeRun # noqa: E402 +from app.models.user import User # noqa: E402 + + +async def main(user_uuid: str) -> None: + try: + user_id = UUID(user_uuid) + except ValueError: + print(f"Error: Invalid UUID format: {user_uuid}") + sys.exit(1) + + async with async_session() as session: + # Verify user exists + user_result = await session.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if user is None: + print(f"Error: User {user_id} not found") + sys.exit(1) + + print(f"Found user: {user.email} (display_name: {user.display_name})") + + # Count unowned runs + count_result = await session.execute( + select(NuzlockeRun.id, NuzlockeRun.name).where( + NuzlockeRun.owner_id.is_(None) + ) + ) + unowned_runs = count_result.all() + + if not unowned_runs: + print("No unowned runs found.") + return + + print(f"\nFound {len(unowned_runs)} unowned run(s):") + for run_id, run_name in unowned_runs: + print(f" - [{run_id}] {run_name}") + + # Confirm action + confirm = input(f"\nAssign all {len(unowned_runs)} runs to this user? [y/N] ") + if confirm.lower() != "y": + print("Aborted.") + return + + # Perform the update + await session.execute( + update(NuzlockeRun) + .where(NuzlockeRun.owner_id.is_(None)) + .values(owner_id=user_id) + ) + await session.commit() + + print(f"\nAssigned {len(unowned_runs)} run(s) to user {user.email}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python scripts/assign_unowned_runs.py ") + print("\nExample:") + print(" uv run python scripts/assign_unowned_runs.py 550e8400-e29b-41d4-a716-446655440000") + sys.exit(1) + + asyncio.run(main(sys.argv[1])) diff --git a/backend/src/app/alembic/versions/l3a4b5c6d7e8_add_boss_pokemon_details.py b/backend/src/app/alembic/versions/l3a4b5c6d7e8_add_boss_pokemon_details.py new file mode 100644 index 0000000..d12f9b2 --- /dev/null +++ b/backend/src/app/alembic/versions/l3a4b5c6d7e8_add_boss_pokemon_details.py @@ -0,0 +1,62 @@ +"""add boss pokemon details + +Revision ID: l3a4b5c6d7e8 +Revises: k2f3a4b5c6d7 +Create Date: 2026-03-20 19:30:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "l3a4b5c6d7e8" +down_revision: str | Sequence[str] | None = "k2f3a4b5c6d7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Add ability reference + op.add_column( + "boss_pokemon", + sa.Column( + "ability_id", sa.Integer(), sa.ForeignKey("abilities.id"), nullable=True + ), + ) + op.create_index("ix_boss_pokemon_ability_id", "boss_pokemon", ["ability_id"]) + + # Add held item (plain string) + op.add_column( + "boss_pokemon", + sa.Column("held_item", sa.String(50), nullable=True), + ) + + # Add nature (plain string) + op.add_column( + "boss_pokemon", + sa.Column("nature", sa.String(20), nullable=True), + ) + + # Add move references (up to 4 moves) + for i in range(1, 5): + op.add_column( + "boss_pokemon", + sa.Column( + f"move{i}_id", sa.Integer(), sa.ForeignKey("moves.id"), nullable=True + ), + ) + op.create_index(f"ix_boss_pokemon_move{i}_id", "boss_pokemon", [f"move{i}_id"]) + + +def downgrade() -> None: + for i in range(1, 5): + op.drop_index(f"ix_boss_pokemon_move{i}_id", "boss_pokemon") + op.drop_column("boss_pokemon", f"move{i}_id") + + op.drop_column("boss_pokemon", "nature") + op.drop_column("boss_pokemon", "held_item") + op.drop_index("ix_boss_pokemon_ability_id", "boss_pokemon") + op.drop_column("boss_pokemon", "ability_id") diff --git a/backend/src/app/alembic/versions/m4b5c6d7e8f9_add_boss_result_team.py b/backend/src/app/alembic/versions/m4b5c6d7e8f9_add_boss_result_team.py new file mode 100644 index 0000000..221a892 --- /dev/null +++ b/backend/src/app/alembic/versions/m4b5c6d7e8f9_add_boss_result_team.py @@ -0,0 +1,44 @@ +"""add boss result team + +Revision ID: m4b5c6d7e8f9 +Revises: l3a4b5c6d7e8 +Create Date: 2026-03-20 20:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "m4b5c6d7e8f9" +down_revision: str | Sequence[str] | None = "l3a4b5c6d7e8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "boss_result_team", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "boss_result_id", + sa.Integer(), + sa.ForeignKey("boss_results.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "encounter_id", + sa.Integer(), + sa.ForeignKey("encounters.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("level", sa.SmallInteger(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("boss_result_team") diff --git a/backend/src/app/alembic/versions/n5c6d7e8f9a0_create_users_table.py b/backend/src/app/alembic/versions/n5c6d7e8f9a0_create_users_table.py new file mode 100644 index 0000000..7ef40f0 --- /dev/null +++ b/backend/src/app/alembic/versions/n5c6d7e8f9a0_create_users_table.py @@ -0,0 +1,37 @@ +"""create users table + +Revision ID: n5c6d7e8f9a0 +Revises: m4b5c6d7e8f9 +Create Date: 2026-03-20 22:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "n5c6d7e8f9a0" +down_revision: str | Sequence[str] | None = "m4b5c6d7e8f9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.UUID(), primary_key=True), + sa.Column("email", sa.String(255), nullable=False, unique=True, index=True), + sa.Column("display_name", sa.String(100), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("users") diff --git a/backend/src/app/alembic/versions/o6d7e8f9a0b1_add_owner_and_visibility_to_runs.py b/backend/src/app/alembic/versions/o6d7e8f9a0b1_add_owner_and_visibility_to_runs.py new file mode 100644 index 0000000..2d23062 --- /dev/null +++ b/backend/src/app/alembic/versions/o6d7e8f9a0b1_add_owner_and_visibility_to_runs.py @@ -0,0 +1,60 @@ +"""add owner_id and visibility to runs + +Revision ID: o6d7e8f9a0b1 +Revises: n5c6d7e8f9a0 +Create Date: 2026-03-20 22:01:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "o6d7e8f9a0b1" +down_revision: str | Sequence[str] | None = "n5c6d7e8f9a0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Create visibility enum + visibility_enum = sa.Enum("public", "private", name="run_visibility") + visibility_enum.create(op.get_bind(), checkfirst=True) + + # Add owner_id (nullable FK to users) + op.add_column( + "nuzlocke_runs", + sa.Column("owner_id", sa.UUID(), nullable=True), + ) + op.create_foreign_key( + "fk_nuzlocke_runs_owner_id", + "nuzlocke_runs", + "users", + ["owner_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("ix_nuzlocke_runs_owner_id", "nuzlocke_runs", ["owner_id"]) + + # Add visibility column with default 'public' + op.add_column( + "nuzlocke_runs", + sa.Column( + "visibility", + visibility_enum, + nullable=False, + server_default="public", + ), + ) + + +def downgrade() -> None: + op.drop_column("nuzlocke_runs", "visibility") + op.drop_index("ix_nuzlocke_runs_owner_id", table_name="nuzlocke_runs") + op.drop_constraint("fk_nuzlocke_runs_owner_id", "nuzlocke_runs", type_="foreignkey") + op.drop_column("nuzlocke_runs", "owner_id") + + # Drop the enum type + sa.Enum(name="run_visibility").drop(op.get_bind(), checkfirst=True) diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py index 187203a..807038b 100644 --- a/backend/src/app/api/bosses.py +++ b/backend/src/app/api/bosses.py @@ -5,10 +5,13 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.auth import AuthUser, require_auth from app.core.database import get_session from app.models.boss_battle import BossBattle from app.models.boss_pokemon import BossPokemon from app.models.boss_result import BossResult +from app.models.boss_result_team import BossResultTeam +from app.models.encounter import Encounter from app.models.game import Game from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon @@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses router = APIRouter() +def _boss_pokemon_load_options(): + """Standard eager-loading options for BossPokemon relationships.""" + return ( + selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), + selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability), + selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1), + selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2), + selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3), + selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4), + ) + + async def _get_version_group_id(session: AsyncSession, game_id: int) -> int: game = await session.get(Game, game_id) if game is None: @@ -53,7 +68,7 @@ async def list_bosses( query = ( select(BossBattle) .where(BossBattle.version_group_id == vg_id) - .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) + .options(*_boss_pokemon_load_options()) .order_by(BossBattle.order) ) @@ -71,6 +86,7 @@ async def reorder_bosses( game_id: int, data: BossReorderRequest, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -101,7 +117,7 @@ async def reorder_bosses( result = await session.execute( select(BossBattle) .where(BossBattle.version_group_id == vg_id) - .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) + .options(*_boss_pokemon_load_options()) .order_by(BossBattle.order) ) return result.scalars().all() @@ -114,6 +130,7 @@ async def create_boss( game_id: int, data: BossBattleCreate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -133,7 +150,7 @@ async def create_boss( result = await session.execute( select(BossBattle) .where(BossBattle.id == boss.id) - .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) + .options(*_boss_pokemon_load_options()) ) return result.scalar_one() @@ -144,6 +161,7 @@ async def update_boss( boss_id: int, data: BossBattleUpdate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -158,7 +176,7 @@ async def update_boss( result = await session.execute( select(BossBattle) .where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id) - .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) + .options(*_boss_pokemon_load_options()) ) boss = result.scalar_one_or_none() if boss is None: @@ -174,7 +192,7 @@ async def update_boss( result = await session.execute( select(BossBattle) .where(BossBattle.id == boss.id) - .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) + .options(*_boss_pokemon_load_options()) ) return result.scalar_one() @@ -184,6 +202,7 @@ async def delete_boss( game_id: int, boss_id: int, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -206,6 +225,7 @@ async def bulk_import_bosses( game_id: int, items: list[BulkBossItem], session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -248,6 +268,7 @@ async def set_boss_team( boss_id: int, team: list[BossPokemonInput], session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -272,6 +293,13 @@ async def set_boss_team( level=item.level, order=item.order, condition_label=item.condition_label, + ability_id=item.ability_id, + held_item=item.held_item, + nature=item.nature, + move1_id=item.move1_id, + move2_id=item.move2_id, + move3_id=item.move3_id, + move4_id=item.move4_id, ) session.add(bp) @@ -286,7 +314,7 @@ async def set_boss_team( result = await session.execute( select(BossBattle) .where(BossBattle.id == boss.id) - .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) + .options(*_boss_pokemon_load_options()) ) return result.scalar_one() @@ -301,7 +329,10 @@ async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_ses raise HTTPException(status_code=404, detail="Run not found") result = await session.execute( - select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id) + select(BossResult) + .where(BossResult.run_id == run_id) + .options(selectinload(BossResult.team)) + .order_by(BossResult.id) ) return result.scalars().all() @@ -313,6 +344,7 @@ async def create_boss_result( run_id: int, data: BossResultCreate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): run = await session.get(NuzlockeRun, run_id) if run is None: @@ -322,12 +354,30 @@ async def create_boss_result( if boss is None: raise HTTPException(status_code=404, detail="Boss battle not found") + # Validate team encounter IDs belong to this run + if data.team: + encounter_ids = [t.encounter_id for t in data.team] + enc_result = await session.execute( + select(Encounter).where( + Encounter.id.in_(encounter_ids), Encounter.run_id == run_id + ) + ) + found_encounters = {e.id for e in enc_result.scalars().all()} + missing = [eid for eid in encounter_ids if eid not in found_encounters] + if missing: + raise HTTPException( + status_code=400, + detail=f"Encounters not found in this run: {missing}", + ) + # Check for existing result (upsert) existing = await session.execute( - select(BossResult).where( + select(BossResult) + .where( BossResult.run_id == run_id, BossResult.boss_battle_id == data.boss_battle_id, ) + .options(selectinload(BossResult.team)) ) result = existing.scalar_one_or_none() @@ -335,6 +385,10 @@ async def create_boss_result( result.result = data.result result.attempts = data.attempts result.completed_at = datetime.now(UTC) if data.result == "won" else None + # Clear existing team and add new + for tm in result.team: + await session.delete(tm) + await session.flush() else: result = BossResult( run_id=run_id, @@ -344,10 +398,26 @@ async def create_boss_result( completed_at=datetime.now(UTC) if data.result == "won" else None, ) session.add(result) + await session.flush() + + # Add team members + for tm in data.team: + team_member = BossResultTeam( + boss_result_id=result.id, + encounter_id=tm.encounter_id, + level=tm.level, + ) + session.add(team_member) await session.commit() - await session.refresh(result) - return result + + # Re-fetch with team loaded + fresh = await session.execute( + select(BossResult) + .where(BossResult.id == result.id) + .options(selectinload(BossResult.team)) + ) + return fresh.scalar_one() @router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204) @@ -355,6 +425,7 @@ async def delete_boss_result( run_id: int, result_id: int, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): result = await session.execute( select(BossResult).where( diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index d5a9b23..fc92d37 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -5,6 +5,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload +from app.core.auth import AuthUser, require_auth from app.core.database import get_session from app.models.encounter import Encounter from app.models.evolution import Evolution @@ -35,6 +36,7 @@ async def create_encounter( run_id: int, data: EncounterCreate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): # Validate run exists run = await session.get(NuzlockeRun, run_id) @@ -137,6 +139,7 @@ async def update_encounter( encounter_id: int, data: EncounterUpdate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): encounter = await session.get(Encounter, encounter_id) if encounter is None: @@ -163,7 +166,9 @@ async def update_encounter( @router.delete("/encounters/{encounter_id}", status_code=204) async def delete_encounter( - encounter_id: int, session: AsyncSession = Depends(get_session) + encounter_id: int, + session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): encounter = await session.get(Encounter, encounter_id) if encounter is None: @@ -195,6 +200,7 @@ async def delete_encounter( async def bulk_randomize_encounters( run_id: int, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): # 1. Validate run run = await session.get(NuzlockeRun, run_id) diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py index dfebb7a..6dc8dde 100644 --- a/backend/src/app/api/games.py +++ b/backend/src/app/api/games.py @@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.auth import AuthUser, require_auth from app.core.database import get_session from app.models.boss_battle import BossBattle from app.models.game import Game @@ -228,7 +229,11 @@ async def list_game_routes( @router.post("", response_model=GameResponse, status_code=201) -async def create_game(data: GameCreate, session: AsyncSession = Depends(get_session)): +async def create_game( + data: GameCreate, + session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), +): existing = await session.execute(select(Game).where(Game.slug == data.slug)) if existing.scalar_one_or_none() is not None: raise HTTPException( @@ -244,7 +249,10 @@ async def create_game(data: GameCreate, session: AsyncSession = Depends(get_sess @router.put("/{game_id}", response_model=GameResponse) async def update_game( - game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session) + game_id: int, + data: GameUpdate, + session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): game = await session.get(Game, game_id) if game is None: @@ -269,7 +277,11 @@ async def update_game( @router.delete("/{game_id}", status_code=204) -async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)): +async def delete_game( + game_id: int, + session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), +): result = await session.execute( select(Game).where(Game.id == game_id).options(selectinload(Game.runs)) ) @@ -323,7 +335,10 @@ async def delete_game(game_id: int, session: AsyncSession = Depends(get_session) @router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201) async def create_route( - game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session) + game_id: int, + data: RouteCreate, + session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -339,6 +354,7 @@ async def reorder_routes( game_id: int, data: RouteReorderRequest, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -365,6 +381,7 @@ async def update_route( route_id: int, data: RouteUpdate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -385,6 +402,7 @@ async def delete_route( game_id: int, route_id: int, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) @@ -419,6 +437,7 @@ async def bulk_import_routes( game_id: int, items: list[BulkRouteItem], session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): vg_id = await _get_version_group_id(session, game_id) diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 0f00270..5ccabf2 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -6,6 +6,7 @@ from sqlalchemy import update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.auth import AuthUser, require_auth from app.core.database import get_session from app.models.encounter import Encounter from app.models.evolution import Evolution @@ -437,7 +438,9 @@ async def get_genlocke_lineages( @router.post("", response_model=GenlockeResponse, status_code=201) async def create_genlocke( - data: GenlockeCreate, session: AsyncSession = Depends(get_session) + data: GenlockeCreate, + session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): if not data.game_ids: raise HTTPException(status_code=400, detail="At least one game is required") @@ -568,6 +571,7 @@ async def advance_leg( leg_order: int, data: AdvanceLegRequest | None = None, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): # Load genlocke with legs result = await session.execute( @@ -822,6 +826,7 @@ async def update_genlocke( genlocke_id: int, data: GenlockeUpdate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): result = await session.execute( select(Genlocke) @@ -858,6 +863,7 @@ async def update_genlocke( async def delete_genlocke( genlocke_id: int, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): genlocke = await session.get(Genlocke, genlocke_id) if genlocke is None: @@ -889,6 +895,7 @@ async def add_leg( genlocke_id: int, data: AddLegRequest, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): genlocke = await session.get(Genlocke, genlocke_id) if genlocke is None: @@ -931,6 +938,7 @@ async def remove_leg( genlocke_id: int, leg_id: int, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): result = await session.execute( select(GenlockeLeg).where( diff --git a/backend/src/app/api/journal_entries.py b/backend/src/app/api/journal_entries.py index 045530b..80408a9 100644 --- a/backend/src/app/api/journal_entries.py +++ b/backend/src/app/api/journal_entries.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import AuthUser, require_auth from app.core.database import get_session from app.models.boss_result import BossResult from app.models.journal_entry import JournalEntry @@ -45,6 +46,7 @@ async def create_journal_entry( run_id: int, data: JournalEntryCreate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): # Validate run exists run = await session.get(NuzlockeRun, run_id) @@ -97,6 +99,7 @@ async def update_journal_entry( entry_id: UUID, data: JournalEntryUpdate, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): result = await session.execute( select(JournalEntry).where( @@ -135,6 +138,7 @@ async def delete_journal_entry( run_id: int, entry_id: UUID, session: AsyncSession = Depends(get_session), + _user: AuthUser = Depends(require_auth), ): result = await session.execute( select(JournalEntry).where( diff --git a/backend/src/app/api/moves_abilities.py b/backend/src/app/api/moves_abilities.py new file mode 100644 index 0000000..b3e1b5f --- /dev/null +++ b/backend/src/app/api/moves_abilities.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.models.ability import Ability +from app.models.move import Move +from app.schemas.move import ( + AbilityResponse, + MoveResponse, + PaginatedAbilityResponse, + PaginatedMoveResponse, +) + +router = APIRouter() + + +@router.get("/moves", response_model=PaginatedMoveResponse) +async def list_moves( + search: str | None = None, + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + session: AsyncSession = Depends(get_session), +): + query = select(Move) + + if search: + query = query.where(Move.name.ilike(f"%{search}%")) + + query = query.order_by(Move.name).offset(offset).limit(limit) + result = await session.execute(query) + items = result.scalars().all() + + # Count total + count_query = select(func.count()).select_from(Move) + if search: + count_query = count_query.where(Move.name.ilike(f"%{search}%")) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + return PaginatedMoveResponse(items=items, total=total, limit=limit, offset=offset) + + +@router.get("/moves/{move_id}", response_model=MoveResponse) +async def get_move( + move_id: int, + session: AsyncSession = Depends(get_session), +): + move = await session.get(Move, move_id) + if move is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="Move not found") + return move + + +@router.get("/abilities", response_model=PaginatedAbilityResponse) +async def list_abilities( + search: str | None = None, + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + session: AsyncSession = Depends(get_session), +): + query = select(Ability) + + if search: + query = query.where(Ability.name.ilike(f"%{search}%")) + + query = query.order_by(Ability.name).offset(offset).limit(limit) + result = await session.execute(query) + items = result.scalars().all() + + # Count total + count_query = select(func.count()).select_from(Ability) + if search: + count_query = count_query.where(Ability.name.ilike(f"%{search}%")) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + return PaginatedAbilityResponse( + items=items, total=total, limit=limit, offset=offset + ) + + +@router.get("/abilities/{ability_id}", response_model=AbilityResponse) +async def get_ability( + ability_id: int, + session: AsyncSession = Depends(get_session), +): + ability = await session.get(Ability, ability_id) + if ability is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="Ability not found") + return ability diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index fd5e5a5..6944f82 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -9,13 +9,16 @@ from app.api import ( genlockes, health, journal_entries, + moves_abilities, pokemon, runs, stats, + users, ) api_router = APIRouter() api_router.include_router(health.router) +api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(games.router, prefix="/games", tags=["games"]) api_router.include_router(pokemon.router, tags=["pokemon"]) api_router.include_router(evolutions.router, tags=["evolutions"]) @@ -25,4 +28,5 @@ api_router.include_router(genlockes.router, prefix="/genlockes", tags=["genlocke api_router.include_router(encounters.router, tags=["encounters"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(bosses.router, tags=["bosses"]) +api_router.include_router(moves_abilities.router, tags=["moves", "abilities"]) api_router.include_router(export.router, prefix="/export", tags=["export"]) diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 2ea9dda..52d9d1e 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -1,10 +1,12 @@ from datetime import UTC, datetime +from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload +from app.core.auth import AuthUser, get_current_user, require_auth from app.core.database import get_session from app.models.boss_result import BossResult from app.models.encounter import Encounter @@ -12,8 +14,10 @@ from app.models.evolution import Evolution from app.models.game import Game from app.models.genlocke import GenlockeLeg from app.models.genlocke_transfer import GenlockeTransfer -from app.models.nuzlocke_run import NuzlockeRun +from app.models.nuzlocke_run import NuzlockeRun, RunVisibility +from app.models.user import User from app.schemas.run import ( + OwnerResponse, RunCreate, RunDetailResponse, RunGenlockeContext, @@ -157,41 +161,136 @@ async def _compute_lineage_suggestion( return f"{base_name} {numeral}" +def _build_run_response(run: NuzlockeRun) -> RunResponse: + """Build RunResponse with owner info if present.""" + owner = None + if run.owner: + owner = OwnerResponse(id=run.owner.id, display_name=run.owner.display_name) + return RunResponse( + id=run.id, + game_id=run.game_id, + name=run.name, + status=run.status, + rules=run.rules, + hof_encounter_ids=run.hof_encounter_ids, + naming_scheme=run.naming_scheme, + visibility=run.visibility, + owner=owner, + started_at=run.started_at, + completed_at=run.completed_at, + ) + + +def _check_run_access( + run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False +) -> None: + """ + Check if user can access the run. + Raises 403 for private runs if user is not owner. + If require_owner=True, always requires ownership (for mutations). + """ + if run.owner_id is None: + # Unowned runs are accessible by everyone (legacy) + if require_owner: + raise HTTPException( + status_code=403, detail="Only the run owner can perform this action" + ) + return + + user_id = UUID(user.id) if user else None + + if require_owner: + if user_id != run.owner_id: + raise HTTPException( + status_code=403, detail="Only the run owner can perform this action" + ) + return + + if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id: + raise HTTPException(status_code=403, detail="This run is private") + + @router.post("", response_model=RunResponse, status_code=201) -async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)): +async def create_run( + data: RunCreate, + session: AsyncSession = Depends(get_session), + user: AuthUser = Depends(require_auth), +): # Validate game exists game = await session.get(Game, data.game_id) if game is None: raise HTTPException(status_code=404, detail="Game not found") + # Ensure user exists in local DB + user_id = UUID(user.id) + db_user = await session.get(User, user_id) + if db_user is None: + db_user = User(id=user_id, email=user.email or "") + session.add(db_user) + run = NuzlockeRun( game_id=data.game_id, + owner_id=user_id, name=data.name, status="active", + visibility=data.visibility, rules=data.rules, naming_scheme=data.naming_scheme, ) session.add(run) await session.commit() - await session.refresh(run) - return run + + # Reload with owner relationship + result = await session.execute( + select(NuzlockeRun) + .where(NuzlockeRun.id == run.id) + .options(joinedload(NuzlockeRun.owner)) + ) + run = result.scalar_one() + return _build_run_response(run) @router.get("", response_model=list[RunResponse]) -async def list_runs(session: AsyncSession = Depends(get_session)): - result = await session.execute( - select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc()) - ) - return result.scalars().all() +async def list_runs( + request: Request, + session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), +): + """ + List runs. Shows public runs and user's own private runs. + """ + query = select(NuzlockeRun).options(joinedload(NuzlockeRun.owner)) + + if user: + user_id = UUID(user.id) + # Show public runs OR runs owned by current user + query = query.where( + (NuzlockeRun.visibility == RunVisibility.PUBLIC) + | (NuzlockeRun.owner_id == user_id) + ) + else: + # Anonymous: only public runs + query = query.where(NuzlockeRun.visibility == RunVisibility.PUBLIC) + + query = query.order_by(NuzlockeRun.started_at.desc()) + result = await session.execute(query) + runs = result.scalars().all() + return [_build_run_response(run) for run in runs] @router.get("/{run_id}", response_model=RunDetailResponse) -async def get_run(run_id: int, session: AsyncSession = Depends(get_session)): +async def get_run( + run_id: int, + request: Request, + session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), +): result = await session.execute( select(NuzlockeRun) .where(NuzlockeRun.id == run_id) .options( joinedload(NuzlockeRun.game), + joinedload(NuzlockeRun.owner), selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon), selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon), selectinload(NuzlockeRun.encounters).joinedload(Encounter.route), @@ -201,6 +300,9 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)): if run is None: raise HTTPException(status_code=404, detail="Run not found") + # Check visibility access + _check_run_access(run, user) + # Check if this run belongs to a genlocke genlocke_context = None leg_result = await session.execute( @@ -262,11 +364,20 @@ async def update_run( run_id: int, data: RunUpdate, session: AsyncSession = Depends(get_session), + user: AuthUser = Depends(require_auth), ): - run = await session.get(NuzlockeRun, run_id) + result = await session.execute( + select(NuzlockeRun) + .where(NuzlockeRun.id == run_id) + .options(joinedload(NuzlockeRun.owner)) + ) + run = result.scalar_one_or_none() if run is None: raise HTTPException(status_code=404, detail="Run not found") + # Check ownership for mutations (unowned runs allow anyone for backwards compat) + _check_run_access(run, user, require_owner=run.owner_id is not None) + update_data = data.model_dump(exclude_unset=True) # Validate hof_encounter_ids if provided @@ -352,16 +463,30 @@ async def update_run( genlocke.status = "completed" await session.commit() - await session.refresh(run) - return run + + # Reload with owner relationship + result = await session.execute( + select(NuzlockeRun) + .where(NuzlockeRun.id == run.id) + .options(joinedload(NuzlockeRun.owner)) + ) + run = result.scalar_one() + return _build_run_response(run) @router.delete("/{run_id}", status_code=204) -async def delete_run(run_id: int, session: AsyncSession = Depends(get_session)): +async def delete_run( + run_id: int, + session: AsyncSession = Depends(get_session), + user: AuthUser = Depends(require_auth), +): run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") + # Check ownership for deletion (unowned runs allow anyone for backwards compat) + _check_run_access(run, user, require_owner=run.owner_id is not None) + # Block deletion if run is linked to a genlocke leg leg_result = await session.execute( select(GenlockeLeg).where(GenlockeLeg.run_id == run_id) diff --git a/backend/src/app/api/users.py b/backend/src/app/api/users.py new file mode 100644 index 0000000..bfc3d38 --- /dev/null +++ b/backend/src/app/api/users.py @@ -0,0 +1,106 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import AuthUser, require_auth +from app.core.database import get_session +from app.models.user import User +from app.schemas.base import CamelModel + +router = APIRouter() + + +class UserResponse(CamelModel): + id: UUID + email: str + display_name: str | None = None + + +@router.post("/me", response_model=UserResponse) +async def sync_current_user( + session: AsyncSession = Depends(get_session), + auth_user: AuthUser = Depends(require_auth), +): + """ + Sync the current authenticated user from Supabase to local DB. + Creates user on first login, updates email if changed. + """ + user_id = UUID(auth_user.id) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + # First login - create user record + user = User( + id=user_id, + email=auth_user.email or "", + display_name=None, + ) + session.add(user) + elif auth_user.email and user.email != auth_user.email: + # Email changed in Supabase - update local record + user.email = auth_user.email + + await session.commit() + await session.refresh(user) + return user + + +@router.get("/me", response_model=UserResponse) +async def get_current_user( + session: AsyncSession = Depends(get_session), + auth_user: AuthUser = Depends(require_auth), +): + """Get the current authenticated user's profile.""" + user_id = UUID(auth_user.id) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + # Auto-create if not exists (shouldn't happen if /me POST is called on login) + user = User( + id=user_id, + email=auth_user.email or "", + display_name=None, + ) + session.add(user) + await session.commit() + await session.refresh(user) + + return user + + +class UserUpdateRequest(CamelModel): + display_name: str | None = None + + +@router.patch("/me", response_model=UserResponse) +async def update_current_user( + data: UserUpdateRequest, + session: AsyncSession = Depends(get_session), + auth_user: AuthUser = Depends(require_auth), +): + """Update the current user's profile (display name).""" + user_id = UUID(auth_user.id) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + user = User( + id=user_id, + email=auth_user.email or "", + display_name=data.display_name, + ) + session.add(user) + else: + if data.display_name is not None: + user.display_name = data.display_name + + await session.commit() + await session.refresh(user) + return user diff --git a/backend/src/app/core/auth.py b/backend/src/app/core/auth.py new file mode 100644 index 0000000..7cfc7d2 --- /dev/null +++ b/backend/src/app/core/auth.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass + +import jwt +from fastapi import Depends, HTTPException, Request, status + +from app.core.config import settings + + +@dataclass +class AuthUser: + """Authenticated user info extracted from JWT.""" + + id: str # Supabase user UUID + email: str | None = None + role: str | None = None + + +def _extract_token(request: Request) -> str | None: + """Extract Bearer token from Authorization header.""" + auth_header = request.headers.get("Authorization") + if not auth_header: + return None + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + return parts[1] + + +def _verify_jwt(token: str) -> dict | None: + """Verify JWT against Supabase JWT secret. Returns payload or None.""" + if not settings.supabase_jwt_secret: + return None + try: + payload = jwt.decode( + token, + settings.supabase_jwt_secret, + algorithms=["HS256"], + audience="authenticated", + ) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +def get_current_user(request: Request) -> AuthUser | None: + """ + Extract and verify the current user from the request. + Returns AuthUser if valid token, None otherwise. + """ + token = _extract_token(request) + if not token: + return None + + payload = _verify_jwt(token) + if not payload: + return None + + # Supabase JWT has 'sub' as user ID + user_id = payload.get("sub") + if not user_id: + return None + + return AuthUser( + id=user_id, + email=payload.get("email"), + role=payload.get("role"), + ) + + +def require_auth(user: AuthUser | None = Depends(get_current_user)) -> AuthUser: + """ + Dependency that requires authentication. + Raises 401 if no valid token is present. + """ + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user diff --git a/backend/src/app/core/config.py b/backend/src/app/core/config.py index 9481456..7ef08af 100644 --- a/backend/src/app/core/config.py +++ b/backend/src/app/core/config.py @@ -17,5 +17,10 @@ class Settings(BaseSettings): # Database settings database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/nuzlocke" + # Supabase Auth + supabase_url: str | None = None + supabase_anon_key: str | None = None + supabase_jwt_secret: str | None = None + settings = Settings() diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index 0d38166..e626f5a 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.ability import Ability from app.models.boss_battle import BossBattle from app.models.boss_pokemon import BossPokemon from app.models.boss_result import BossResult +from app.models.boss_result_team import BossResultTeam from app.models.encounter import Encounter from app.models.evolution import Evolution from app.models.game import Game @@ -13,6 +14,7 @@ from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter +from app.models.user import User from app.models.version_group import VersionGroup __all__ = [ @@ -20,6 +22,7 @@ __all__ = [ "BossBattle", "BossPokemon", "BossResult", + "BossResultTeam", "Encounter", "Evolution", "Game", @@ -32,5 +35,6 @@ __all__ = [ "Pokemon", "Route", "RouteEncounter", + "User", "VersionGroup", ] diff --git a/backend/src/app/models/boss_pokemon.py b/backend/src/app/models/boss_pokemon.py index 43f18ce..914c046 100644 --- a/backend/src/app/models/boss_pokemon.py +++ b/backend/src/app/models/boss_pokemon.py @@ -1,8 +1,18 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from sqlalchemy import ForeignKey, SmallInteger, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base +if TYPE_CHECKING: + from app.models.ability import Ability + from app.models.boss_battle import BossBattle + from app.models.move import Move + from app.models.pokemon import Pokemon + class BossPokemon(Base): __tablename__ = "boss_pokemon" @@ -16,8 +26,24 @@ class BossPokemon(Base): order: Mapped[int] = mapped_column(SmallInteger) condition_label: Mapped[str | None] = mapped_column(String(100)) + # Detail fields + ability_id: Mapped[int | None] = mapped_column( + ForeignKey("abilities.id"), index=True + ) + held_item: Mapped[str | None] = mapped_column(String(50)) + nature: Mapped[str | None] = mapped_column(String(20)) + move1_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True) + move2_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True) + move3_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True) + move4_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True) + boss_battle: Mapped[BossBattle] = relationship(back_populates="pokemon") pokemon: Mapped[Pokemon] = relationship() + ability: Mapped[Ability | None] = relationship() + move1: Mapped[Move | None] = relationship(foreign_keys=[move1_id]) + move2: Mapped[Move | None] = relationship(foreign_keys=[move2_id]) + move3: Mapped[Move | None] = relationship(foreign_keys=[move3_id]) + move4: Mapped[Move | None] = relationship(foreign_keys=[move4_id]) def __repr__(self) -> str: return f"" diff --git a/backend/src/app/models/boss_result.py b/backend/src/app/models/boss_result.py index 84b2293..4e90595 100644 --- a/backend/src/app/models/boss_result.py +++ b/backend/src/app/models/boss_result.py @@ -25,6 +25,12 @@ class BossResult(Base): run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results") boss_battle: Mapped[BossBattle] = relationship() + team: Mapped[list[BossResultTeam]] = relationship( + back_populates="boss_result", cascade="all, delete-orphan" + ) def __repr__(self) -> str: - return f"" + return ( + f"" + ) diff --git a/backend/src/app/models/boss_result_team.py b/backend/src/app/models/boss_result_team.py new file mode 100644 index 0000000..29409e1 --- /dev/null +++ b/backend/src/app/models/boss_result_team.py @@ -0,0 +1,26 @@ +from sqlalchemy import ForeignKey, SmallInteger +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class BossResultTeam(Base): + __tablename__ = "boss_result_team" + + id: Mapped[int] = mapped_column(primary_key=True) + boss_result_id: Mapped[int] = mapped_column( + ForeignKey("boss_results.id", ondelete="CASCADE"), index=True + ) + encounter_id: Mapped[int] = mapped_column( + ForeignKey("encounters.id", ondelete="CASCADE"), index=True + ) + level: Mapped[int] = mapped_column(SmallInteger) + + boss_result: Mapped[BossResult] = relationship(back_populates="team") + encounter: Mapped[Encounter] = relationship() + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/src/app/models/nuzlocke_run.py b/backend/src/app/models/nuzlocke_run.py index 1879f65..d523791 100644 --- a/backend/src/app/models/nuzlocke_run.py +++ b/backend/src/app/models/nuzlocke_run.py @@ -1,21 +1,46 @@ -from datetime import datetime +from __future__ import annotations -from sqlalchemy import DateTime, ForeignKey, String, func +from datetime import datetime +from enum import StrEnum +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import DateTime, Enum, ForeignKey, String, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base +if TYPE_CHECKING: + from app.models.boss_result import BossResult + from app.models.encounter import Encounter + from app.models.game import Game + from app.models.journal_entry import JournalEntry + from app.models.user import User + + +class RunVisibility(StrEnum): + PUBLIC = "public" + PRIVATE = "private" + class NuzlockeRun(Base): __tablename__ = "nuzlocke_runs" id: Mapped[int] = mapped_column(primary_key=True) game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True) + owner_id: Mapped[UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) name: Mapped[str] = mapped_column(String(100)) status: Mapped[str] = mapped_column( String(20), index=True ) # active, completed, failed + visibility: Mapped[RunVisibility] = mapped_column( + Enum(RunVisibility, name="run_visibility", create_constraint=False), + default=RunVisibility.PUBLIC, + server_default="public", + ) rules: Mapped[dict] = mapped_column(JSONB, default=dict) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() @@ -25,6 +50,7 @@ class NuzlockeRun(Base): naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True) game: Mapped[Game] = relationship(back_populates="runs") + owner: Mapped[User | None] = relationship(back_populates="runs") encounters: Mapped[list[Encounter]] = relationship(back_populates="run") boss_results: Mapped[list[BossResult]] = relationship(back_populates="run") journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run") diff --git a/backend/src/app/models/user.py b/backend/src/app/models/user.py new file mode 100644 index 0000000..ba7ff53 --- /dev/null +++ b/backend/src/app/models/user.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.nuzlocke_run import NuzlockeRun + + +class User(Base): + __tablename__ = "users" + + id: Mapped[UUID] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(100)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + runs: Mapped[list[NuzlockeRun]] = relationship(back_populates="owner") + + def __repr__(self) -> str: + return f"" diff --git a/backend/src/app/schemas/boss.py b/backend/src/app/schemas/boss.py index bc581e2..6dc982d 100644 --- a/backend/src/app/schemas/boss.py +++ b/backend/src/app/schemas/boss.py @@ -4,6 +4,16 @@ from app.schemas.base import CamelModel from app.schemas.pokemon import PokemonResponse +class MoveRef(CamelModel): + id: int + name: str + + +class AbilityRef(CamelModel): + id: int + name: str + + class BossPokemonResponse(CamelModel): id: int pokemon_id: int @@ -11,6 +21,19 @@ class BossPokemonResponse(CamelModel): order: int condition_label: str | None pokemon: PokemonResponse + # Detail fields + ability_id: int | None = None + ability: AbilityRef | None = None + held_item: str | None = None + nature: str | None = None + move1_id: int | None = None + move2_id: int | None = None + move3_id: int | None = None + move4_id: int | None = None + move1: MoveRef | None = None + move2: MoveRef | None = None + move3: MoveRef | None = None + move4: MoveRef | None = None class BossBattleResponse(CamelModel): @@ -31,6 +54,12 @@ class BossBattleResponse(CamelModel): pokemon: list[BossPokemonResponse] = [] +class BossResultTeamMemberResponse(CamelModel): + id: int + encounter_id: int + level: int + + class BossResultResponse(CamelModel): id: int run_id: int @@ -38,6 +67,7 @@ class BossResultResponse(CamelModel): result: str attempts: int completed_at: datetime | None + team: list[BossResultTeamMemberResponse] = [] # --- Input schemas --- @@ -78,12 +108,26 @@ class BossPokemonInput(CamelModel): level: int order: int condition_label: str | None = None + # Detail fields + ability_id: int | None = None + held_item: str | None = None + nature: str | None = None + move1_id: int | None = None + move2_id: int | None = None + move3_id: int | None = None + move4_id: int | None = None + + +class BossResultTeamMemberInput(CamelModel): + encounter_id: int + level: int class BossResultCreate(CamelModel): boss_battle_id: int result: str attempts: int = 1 + team: list[BossResultTeamMemberInput] = [] class BossReorderItem(CamelModel): diff --git a/backend/src/app/schemas/run.py b/backend/src/app/schemas/run.py index aa1abff..0ab3199 100644 --- a/backend/src/app/schemas/run.py +++ b/backend/src/app/schemas/run.py @@ -1,15 +1,23 @@ from datetime import datetime +from uuid import UUID +from app.models.nuzlocke_run import RunVisibility from app.schemas.base import CamelModel from app.schemas.encounter import EncounterDetailResponse from app.schemas.game import GameResponse +class OwnerResponse(CamelModel): + id: UUID + display_name: str | None = None + + class RunCreate(CamelModel): game_id: int name: str rules: dict = {} naming_scheme: str | None = None + visibility: RunVisibility = RunVisibility.PUBLIC class RunUpdate(CamelModel): @@ -18,6 +26,7 @@ class RunUpdate(CamelModel): rules: dict | None = None hof_encounter_ids: list[int] | None = None naming_scheme: str | None = None + visibility: RunVisibility | None = None class RunResponse(CamelModel): @@ -28,6 +37,8 @@ class RunResponse(CamelModel): rules: dict hof_encounter_ids: list[int] | None = None naming_scheme: str | None = None + visibility: RunVisibility + owner: OwnerResponse | None = None started_at: datetime completed_at: datetime | None diff --git a/backend/src/app/seeds/inject_test_data.py b/backend/src/app/seeds/inject_test_data.py index 8454a58..7dfc636 100644 --- a/backend/src/app/seeds/inject_test_data.py +++ b/backend/src/app/seeds/inject_test_data.py @@ -87,7 +87,9 @@ RUN_DEFS = [ "name": "Kanto Heartbreak", "status": "failed", "progress": 0.45, - "rules": {"customRules": "- Hardcore mode: no items in battle\n- Set mode only"}, + "rules": { + "customRules": "- Hardcore mode: no items in battle\n- Set mode only" + }, "started_days_ago": 30, "ended_days_ago": 20, }, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1de5386..01010e4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,13 +1,18 @@ import os +import time +import jwt import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine import app.models # noqa: F401 — ensures all models register with Base.metadata +from app.core.auth import AuthUser, get_current_user from app.core.database import Base, get_session from app.main import app +TEST_JWT_SECRET = "test-jwt-secret-for-testing-only" + TEST_DATABASE_URL = os.getenv( "TEST_DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test", @@ -59,3 +64,43 @@ async def client(db_session): transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac + + +@pytest.fixture +def mock_auth_user(): + """Return a mock authenticated user for tests.""" + return AuthUser(id="test-user-123", email="test@example.com", role="authenticated") + + +@pytest.fixture +def auth_override(mock_auth_user): + """Override get_current_user to return a mock user.""" + + def _override(): + return mock_auth_user + + app.dependency_overrides[get_current_user] = _override + yield + app.dependency_overrides.pop(get_current_user, None) + + +@pytest.fixture +async def auth_client(db_session, auth_override): + """Async HTTP client with mocked authentication.""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac + + +@pytest.fixture +def valid_token(): + """Generate a valid JWT token for testing.""" + payload = { + "sub": "test-user-123", + "email": "test@example.com", + "role": "authenticated", + "aud": "authenticated", + "exp": int(time.time()) + 3600, + } + return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256") diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..9ea3817 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,179 @@ +import time + +import jwt +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.auth import AuthUser, get_current_user, require_auth +from app.core.config import settings +from app.main import app + + +@pytest.fixture +def jwt_secret(): + """Provide a test JWT secret.""" + return "test-jwt-secret-for-testing-only" + + +@pytest.fixture +def valid_token(jwt_secret): + """Generate a valid JWT token.""" + payload = { + "sub": "user-123", + "email": "test@example.com", + "role": "authenticated", + "aud": "authenticated", + "exp": int(time.time()) + 3600, + } + return jwt.encode(payload, jwt_secret, algorithm="HS256") + + +@pytest.fixture +def expired_token(jwt_secret): + """Generate an expired JWT token.""" + payload = { + "sub": "user-123", + "email": "test@example.com", + "role": "authenticated", + "aud": "authenticated", + "exp": int(time.time()) - 3600, # Expired 1 hour ago + } + return jwt.encode(payload, jwt_secret, algorithm="HS256") + + +@pytest.fixture +def invalid_token(): + """Generate a token signed with wrong secret.""" + payload = { + "sub": "user-123", + "email": "test@example.com", + "role": "authenticated", + "aud": "authenticated", + "exp": int(time.time()) + 3600, + } + return jwt.encode(payload, "wrong-secret", algorithm="HS256") + + +@pytest.fixture +def auth_client(db_session, jwt_secret, valid_token, monkeypatch): + """Client with valid auth token and configured JWT secret.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + async def _get_client(): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {valid_token}"}, + ) as ac: + yield ac + + return _get_client + + +async def test_get_current_user_valid_token(jwt_secret, valid_token, monkeypatch): + """Test get_current_user returns user for valid token.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + class MockRequest: + headers = {"Authorization": f"Bearer {valid_token}"} + + user = get_current_user(MockRequest()) + assert user is not None + assert user.id == "user-123" + assert user.email == "test@example.com" + assert user.role == "authenticated" + + +async def test_get_current_user_no_token(jwt_secret, monkeypatch): + """Test get_current_user returns None when no token.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + class MockRequest: + headers = {} + + user = get_current_user(MockRequest()) + assert user is None + + +async def test_get_current_user_expired_token(jwt_secret, expired_token, monkeypatch): + """Test get_current_user returns None for expired token.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + class MockRequest: + headers = {"Authorization": f"Bearer {expired_token}"} + + user = get_current_user(MockRequest()) + assert user is None + + +async def test_get_current_user_invalid_token(jwt_secret, invalid_token, monkeypatch): + """Test get_current_user returns None for invalid token.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + class MockRequest: + headers = {"Authorization": f"Bearer {invalid_token}"} + + user = get_current_user(MockRequest()) + assert user is None + + +async def test_get_current_user_malformed_header(jwt_secret, monkeypatch): + """Test get_current_user returns None for malformed auth header.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + class MockRequest: + headers = {"Authorization": "NotBearer token"} + + user = get_current_user(MockRequest()) + assert user is None + + +async def test_require_auth_valid_user(): + """Test require_auth passes through valid user.""" + user = AuthUser(id="user-123", email="test@example.com") + result = require_auth(user) + assert result is user + + +async def test_require_auth_no_user(): + """Test require_auth raises 401 for no user.""" + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + require_auth(None) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Authentication required" + + +async def test_protected_endpoint_without_token(db_session): + """Test that write endpoint returns 401 without token.""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"}) + assert response.status_code == 401 + assert response.json()["detail"] == "Authentication required" + + +async def test_protected_endpoint_with_expired_token( + db_session, jwt_secret, expired_token, monkeypatch +): + """Test that write endpoint returns 401 with expired token.""" + monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {expired_token}"}, + ) as ac: + response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"}) + assert response.status_code == 401 + + +async def test_read_endpoint_without_token(db_session): + """Test that read endpoints work without authentication.""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + response = await ac.get("/runs") + assert response.status_code == 200 diff --git a/backend/tests/test_games.py b/backend/tests/test_games.py index 0626b1c..ef10d61 100644 --- a/backend/tests/test_games.py +++ b/backend/tests/test_games.py @@ -17,9 +17,9 @@ GAME_PAYLOAD = { @pytest.fixture -async def game(client: AsyncClient) -> dict: +async def game(auth_client: AsyncClient) -> dict: """A game created via the API (no version_group_id).""" - response = await client.post(BASE, json=GAME_PAYLOAD) + response = await auth_client.post(BASE, json=GAME_PAYLOAD) assert response.status_code == 201 return response.json() @@ -68,22 +68,24 @@ class TestListGames: class TestCreateGame: - async def test_creates_and_returns_game(self, client: AsyncClient): - response = await client.post(BASE, json=GAME_PAYLOAD) + async def test_creates_and_returns_game(self, auth_client: AsyncClient): + response = await auth_client.post(BASE, json=GAME_PAYLOAD) assert response.status_code == 201 data = response.json() assert data["name"] == "Pokemon Red" assert data["slug"] == "red" assert isinstance(data["id"], int) - async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict): - response = await client.post( + async def test_duplicate_slug_returns_409( + self, auth_client: AsyncClient, game: dict + ): + response = await auth_client.post( BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"} ) assert response.status_code == 409 - async def test_missing_required_field_returns_422(self, client: AsyncClient): - response = await client.post(BASE, json={"name": "Pokemon Red"}) + async def test_missing_required_field_returns_422(self, auth_client: AsyncClient): + response = await auth_client.post(BASE, json={"name": "Pokemon Red"}) assert response.status_code == 422 @@ -113,29 +115,35 @@ class TestGetGame: class TestUpdateGame: - async def test_updates_name(self, client: AsyncClient, game: dict): - response = await client.put( + async def test_updates_name(self, auth_client: AsyncClient, game: dict): + response = await auth_client.put( f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"} ) assert response.status_code == 200 assert response.json()["name"] == "Pokemon Blue" async def test_slug_unchanged_on_partial_update( - self, client: AsyncClient, game: dict + self, auth_client: AsyncClient, game: dict ): - response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"}) + response = await auth_client.put( + f"{BASE}/{game['id']}", json={"name": "New Name"} + ) assert response.json()["slug"] == "red" - async def test_not_found_returns_404(self, client: AsyncClient): - assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404 + async def test_not_found_returns_404(self, auth_client: AsyncClient): + assert ( + await auth_client.put(f"{BASE}/9999", json={"name": "x"}) + ).status_code == 404 - async def test_duplicate_slug_returns_409(self, client: AsyncClient): - await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}) - r1 = await client.post( + async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient): + await auth_client.post( + BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"} + ) + r1 = await auth_client.post( BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"} ) game_id = r1.json()["id"] - response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"}) + response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"}) assert response.status_code == 409 @@ -145,13 +153,13 @@ class TestUpdateGame: class TestDeleteGame: - async def test_deletes_game(self, client: AsyncClient, game: dict): - response = await client.delete(f"{BASE}/{game['id']}") + async def test_deletes_game(self, auth_client: AsyncClient, game: dict): + response = await auth_client.delete(f"{BASE}/{game['id']}") assert response.status_code == 204 - assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404 + assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404 - async def test_not_found_returns_404(self, client: AsyncClient): - assert (await client.delete(f"{BASE}/9999")).status_code == 404 + async def test_not_found_returns_404(self, auth_client: AsyncClient): + assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404 # --------------------------------------------------------------------------- @@ -187,9 +195,9 @@ class TestListByRegion: class TestCreateRoute: - async def test_creates_route(self, client: AsyncClient, game_with_vg: tuple): + async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple): game_id, _ = game_with_vg - response = await client.post( + response = await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "Pallet Town", "order": 1}, ) @@ -200,35 +208,35 @@ class TestCreateRoute: assert isinstance(data["id"], int) async def test_game_detail_includes_route( - self, client: AsyncClient, game_with_vg: tuple + self, auth_client: AsyncClient, game_with_vg: tuple ): game_id, _ = game_with_vg - await client.post( + await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} ) - response = await client.get(f"{BASE}/{game_id}") + response = await auth_client.get(f"{BASE}/{game_id}") routes = response.json()["routes"] assert len(routes) == 1 assert routes[0]["name"] == "Route 1" async def test_game_without_version_group_returns_400( - self, client: AsyncClient, game: dict + self, auth_client: AsyncClient, game: dict ): - response = await client.post( + response = await auth_client.post( f"{BASE}/{game['id']}/routes", json={"name": "Route 1", "order": 1}, ) assert response.status_code == 400 async def test_list_routes_excludes_routes_without_encounters( - self, client: AsyncClient, game_with_vg: tuple + self, auth_client: AsyncClient, game_with_vg: tuple ): """list_game_routes only returns routes that have Pokemon encounters.""" game_id, _ = game_with_vg - await client.post( + await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} ) - response = await client.get(f"{BASE}/{game_id}/routes?flat=true") + response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true") assert response.status_code == 200 assert response.json() == [] @@ -239,14 +247,16 @@ class TestCreateRoute: class TestUpdateRoute: - async def test_updates_route_name(self, client: AsyncClient, game_with_vg: tuple): + async def test_updates_route_name( + self, auth_client: AsyncClient, game_with_vg: tuple + ): game_id, _ = game_with_vg r = ( - await client.post( + await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1} ) ).json() - response = await client.put( + response = await auth_client.put( f"{BASE}/{game_id}/routes/{r['id']}", json={"name": "New Name"}, ) @@ -254,11 +264,11 @@ class TestUpdateRoute: assert response.json()["name"] == "New Name" async def test_route_not_found_returns_404( - self, client: AsyncClient, game_with_vg: tuple + self, auth_client: AsyncClient, game_with_vg: tuple ): game_id, _ = game_with_vg assert ( - await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"}) + await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"}) ).status_code == 404 @@ -268,25 +278,27 @@ class TestUpdateRoute: class TestDeleteRoute: - async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple): + async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple): game_id, _ = game_with_vg r = ( - await client.post( + await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} ) ).json() assert ( - await client.delete(f"{BASE}/{game_id}/routes/{r['id']}") + await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}") ).status_code == 204 # No longer in game detail - detail = (await client.get(f"{BASE}/{game_id}")).json() + detail = (await auth_client.get(f"{BASE}/{game_id}")).json() assert all(route["id"] != r["id"] for route in detail["routes"]) async def test_route_not_found_returns_404( - self, client: AsyncClient, game_with_vg: tuple + self, auth_client: AsyncClient, game_with_vg: tuple ): game_id, _ = game_with_vg - assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404 + assert ( + await auth_client.delete(f"{BASE}/{game_id}/routes/9999") + ).status_code == 404 # --------------------------------------------------------------------------- @@ -295,20 +307,20 @@ class TestDeleteRoute: class TestReorderRoutes: - async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple): + async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple): game_id, _ = game_with_vg r1 = ( - await client.post( + await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1} ) ).json() r2 = ( - await client.post( + await auth_client.post( f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2} ) ).json() - response = await client.put( + response = await auth_client.put( f"{BASE}/{game_id}/routes/reorder", json={ "routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}] diff --git a/backend/tests/test_runs.py b/backend/tests/test_runs.py index d835a7c..ed48f01 100644 --- a/backend/tests/test_runs.py +++ b/backend/tests/test_runs.py @@ -30,9 +30,11 @@ async def game_id(db_session: AsyncSession) -> int: @pytest.fixture -async def run(client: AsyncClient, game_id: int) -> dict: +async def run(auth_client: AsyncClient, game_id: int) -> dict: """An active run created via the API.""" - response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"}) + response = await auth_client.post( + RUNS_BASE, json={"gameId": game_id, "name": "My Run"} + ) assert response.status_code == 201 return response.json() @@ -127,8 +129,8 @@ class TestListRuns: class TestCreateRun: - async def test_creates_active_run(self, client: AsyncClient, game_id: int): - response = await client.post( + async def test_creates_active_run(self, auth_client: AsyncClient, game_id: int): + response = await auth_client.post( RUNS_BASE, json={"gameId": game_id, "name": "New Run"} ) assert response.status_code == 201 @@ -138,20 +140,22 @@ class TestCreateRun: assert data["gameId"] == game_id assert isinstance(data["id"], int) - async def test_rules_stored(self, client: AsyncClient, game_id: int): + async def test_rules_stored(self, auth_client: AsyncClient, game_id: int): rules = {"duplicatesClause": True, "shinyClause": False} - response = await client.post( + response = await auth_client.post( RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules} ) assert response.status_code == 201 assert response.json()["rules"]["duplicatesClause"] is True - async def test_invalid_game_returns_404(self, client: AsyncClient): - response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"}) + async def test_invalid_game_returns_404(self, auth_client: AsyncClient): + response = await auth_client.post( + RUNS_BASE, json={"gameId": 9999, "name": "Run"} + ) assert response.status_code == 404 - async def test_missing_required_returns_422(self, client: AsyncClient): - response = await client.post(RUNS_BASE, json={"name": "Run"}) + async def test_missing_required_returns_422(self, auth_client: AsyncClient): + response = await auth_client.post(RUNS_BASE, json={"name": "Run"}) assert response.status_code == 422 @@ -181,15 +185,17 @@ class TestGetRun: class TestUpdateRun: - async def test_updates_name(self, client: AsyncClient, run: dict): - response = await client.patch( + async def test_updates_name(self, auth_client: AsyncClient, run: dict): + response = await auth_client.patch( f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"} ) assert response.status_code == 200 assert response.json()["name"] == "Renamed" - async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict): - response = await client.patch( + async def test_complete_run_sets_completed_at( + self, auth_client: AsyncClient, run: dict + ): + response = await auth_client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "completed"} ) assert response.status_code == 200 @@ -197,25 +203,27 @@ class TestUpdateRun: assert data["status"] == "completed" assert data["completedAt"] is not None - async def test_fail_run(self, client: AsyncClient, run: dict): - response = await client.patch( + async def test_fail_run(self, auth_client: AsyncClient, run: dict): + response = await auth_client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "failed"} ) assert response.status_code == 200 assert response.json()["status"] == "failed" async def test_ending_already_ended_run_returns_400( - self, client: AsyncClient, run: dict + self, auth_client: AsyncClient, run: dict ): - await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}) - response = await client.patch( + await auth_client.patch( + f"{RUNS_BASE}/{run['id']}", json={"status": "completed"} + ) + response = await auth_client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "failed"} ) assert response.status_code == 400 - async def test_not_found_returns_404(self, client: AsyncClient): + async def test_not_found_returns_404(self, auth_client: AsyncClient): assert ( - await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"}) + await auth_client.patch(f"{RUNS_BASE}/9999", json={"name": "x"}) ).status_code == 404 @@ -225,12 +233,12 @@ class TestUpdateRun: class TestDeleteRun: - async def test_deletes_run(self, client: AsyncClient, run: dict): - assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204 - assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404 + async def test_deletes_run(self, auth_client: AsyncClient, run: dict): + assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204 + assert (await auth_client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404 - async def test_not_found_returns_404(self, client: AsyncClient): - assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404 + async def test_not_found_returns_404(self, auth_client: AsyncClient): + assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404 # --------------------------------------------------------------------------- @@ -239,8 +247,8 @@ class TestDeleteRun: class TestCreateEncounter: - async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict): - response = await client.post( + async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict): + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], @@ -255,8 +263,10 @@ class TestCreateEncounter: assert data["status"] == "caught" assert data["isShiny"] is False - async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict): - response = await client.post( + async def test_invalid_run_returns_404( + self, auth_client: AsyncClient, enc_ctx: dict + ): + response = await auth_client.post( f"{RUNS_BASE}/9999/encounters", json={ "routeId": enc_ctx["standalone_id"], @@ -266,8 +276,10 @@ class TestCreateEncounter: ) assert response.status_code == 404 - async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict): - response = await client.post( + async def test_invalid_route_returns_404( + self, auth_client: AsyncClient, enc_ctx: dict + ): + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": 9999, @@ -278,9 +290,9 @@ class TestCreateEncounter: assert response.status_code == 404 async def test_invalid_pokemon_returns_404( - self, client: AsyncClient, enc_ctx: dict + self, auth_client: AsyncClient, enc_ctx: dict ): - response = await client.post( + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], @@ -290,9 +302,11 @@ class TestCreateEncounter: ) assert response.status_code == 404 - async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict): + async def test_parent_route_rejected_400( + self, auth_client: AsyncClient, enc_ctx: dict + ): """Cannot create an encounter directly on a parent route (use child routes).""" - response = await client.post( + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["parent_id"], @@ -303,10 +317,10 @@ class TestCreateEncounter: assert response.status_code == 400 async def test_route_lock_prevents_second_sibling_encounter( - self, client: AsyncClient, enc_ctx: dict + self, auth_client: AsyncClient, enc_ctx: dict ): """Once a sibling child has an encounter, other siblings in the group return 409.""" - await client.post( + await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child1_id"], @@ -314,7 +328,7 @@ class TestCreateEncounter: "status": "caught", }, ) - response = await client.post( + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child2_id"], @@ -325,11 +339,11 @@ class TestCreateEncounter: assert response.status_code == 409 async def test_shiny_bypasses_route_lock( - self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession + self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession ): """A shiny encounter bypasses the route-lock when shinyClause is enabled.""" # First encounter occupies the group - await client.post( + await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child1_id"], @@ -338,7 +352,7 @@ class TestCreateEncounter: }, ) # Shiny encounter on sibling should succeed - response = await client.post( + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child2_id"], @@ -351,7 +365,7 @@ class TestCreateEncounter: assert response.json()["isShiny"] is True async def test_gift_bypasses_route_lock_when_clause_on( - self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession + self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession ): """A gift encounter bypasses route-lock when giftClause is enabled.""" # Enable giftClause on the run @@ -359,7 +373,7 @@ class TestCreateEncounter: run.rules = {"shinyClause": True, "giftClause": True} await db_session.commit() - await client.post( + await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child1_id"], @@ -367,7 +381,7 @@ class TestCreateEncounter: "status": "caught", }, ) - response = await client.post( + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child2_id"], @@ -387,8 +401,8 @@ class TestCreateEncounter: class TestUpdateEncounter: @pytest.fixture - async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict: - response = await client.post( + async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict: + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], @@ -398,17 +412,17 @@ class TestUpdateEncounter: ) return response.json() - async def test_updates_nickname(self, client: AsyncClient, encounter: dict): - response = await client.patch( + async def test_updates_nickname(self, auth_client: AsyncClient, encounter: dict): + response = await auth_client.patch( f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"} ) assert response.status_code == 200 assert response.json()["nickname"] == "Sparky" async def test_updates_status_to_fainted( - self, client: AsyncClient, encounter: dict + self, auth_client: AsyncClient, encounter: dict ): - response = await client.patch( + response = await auth_client.patch( f"{ENC_BASE}/{encounter['id']}", json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"}, ) @@ -418,9 +432,9 @@ class TestUpdateEncounter: assert data["faintLevel"] == 12 assert data["deathCause"] == "wild battle" - async def test_not_found_returns_404(self, client: AsyncClient): + async def test_not_found_returns_404(self, auth_client: AsyncClient): assert ( - await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"}) + await auth_client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"}) ).status_code == 404 @@ -431,8 +445,8 @@ class TestUpdateEncounter: class TestDeleteEncounter: @pytest.fixture - async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict: - response = await client.post( + async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict: + response = await auth_client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], @@ -443,12 +457,14 @@ class TestDeleteEncounter: return response.json() async def test_deletes_encounter( - self, client: AsyncClient, encounter: dict, enc_ctx: dict + self, auth_client: AsyncClient, encounter: dict, enc_ctx: dict ): - assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204 + assert ( + await auth_client.delete(f"{ENC_BASE}/{encounter['id']}") + ).status_code == 204 # Run detail should no longer include it - detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json() + detail = (await auth_client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json() assert all(e["id"] != encounter["id"] for e in detail["encounters"]) - async def test_not_found_returns_404(self, client: AsyncClient): - assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404 + async def test_not_found_returns_404(self, auth_client: AsyncClient): + assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404 diff --git a/docs/supabase-auth-setup.md b/docs/supabase-auth-setup.md new file mode 100644 index 0000000..df86112 --- /dev/null +++ b/docs/supabase-auth-setup.md @@ -0,0 +1,128 @@ +# Supabase Auth Setup + +This guide walks through setting up Supabase authentication for local development. + +## 1. Create a Supabase Project + +1. Go to [supabase.com](https://supabase.com) and sign in +2. Click "New project" +3. Choose your organization, enter a project name (e.g., `nuzlocke-tracker-dev`), and set a database password +4. Select a region close to you +5. Wait for the project to finish provisioning + +## 2. Get Your Project Credentials + +From the Supabase dashboard: + +1. Go to **Project Settings** > **API** +2. Copy the following values: + - **Project URL** -> `SUPABASE_URL` / `VITE_SUPABASE_URL` + - **anon public** key -> `SUPABASE_ANON_KEY` / `VITE_SUPABASE_ANON_KEY` +3. Go to **Project Settings** > **API** > **JWT Settings** +4. Copy the **JWT Secret** -> `SUPABASE_JWT_SECRET` + +## 3. Enable Email/Password Auth + +1. Go to **Authentication** > **Providers** +2. Ensure **Email** provider is enabled (it's enabled by default) +3. Configure options as needed: + - **Confirm email**: Enable for production, disable for local dev convenience + - **Secure email change**: Recommended enabled + +## 4. Configure Google OAuth + +### Google Cloud Console Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Go to **APIs & Services** > **OAuth consent screen** + - Choose "External" user type + - Fill in app name, user support email, and developer contact + - Add scopes: `email`, `profile`, `openid` + - Add test users if in testing mode +4. Go to **APIs & Services** > **Credentials** +5. Click **Create Credentials** > **OAuth client ID** +6. Select "Web application" +7. Add authorized redirect URIs: + - `https://.supabase.co/auth/v1/callback` + - For local dev: `http://localhost:5173/auth/callback` +8. Copy the **Client ID** and **Client Secret** + +### Supabase Setup + +1. Go to **Authentication** > **Providers** > **Google** +2. Enable the provider +3. Paste the **Client ID** and **Client Secret** from Google +4. Save + +## 5. Configure Discord OAuth + +### Discord Developer Portal Setup + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Click **New Application** and give it a name +3. Go to **OAuth2** > **General** +4. Add redirect URIs: + - `https://.supabase.co/auth/v1/callback` + - For local dev: `http://localhost:5173/auth/callback` +5. Copy the **Client ID** and **Client Secret** + +### Supabase Setup + +1. Go to **Authentication** > **Providers** > **Discord** +2. Enable the provider +3. Paste the **Client ID** and **Client Secret** from Discord +4. Save + +## 6. Configure Environment Variables + +### Backend (.env) + +```bash +SUPABASE_URL=https://your-project-ref.supabase.co +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +SUPABASE_JWT_SECRET=your-jwt-secret-from-dashboard +``` + +### Frontend (.env) + +```bash +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +## 7. Configure Redirect URLs + +In Supabase Dashboard: + +1. Go to **Authentication** > **URL Configuration** +2. Set **Site URL**: `http://localhost:5173` (for local dev) +3. Add **Redirect URLs**: + - `http://localhost:5173/auth/callback` + - `http://localhost:5173/**` (for flexibility during dev) + +For production, add your production URLs here as well. + +## Verification + +After setup, you can verify by: + +1. Starting the app with `docker compose up` +2. Navigating to the login page +3. Testing email/password signup +4. Testing Google and Discord OAuth flows + +## Troubleshooting + +### "Invalid redirect URI" error +- Ensure the callback URL in your OAuth provider matches exactly what Supabase expects +- Check that your Site URL in Supabase matches your app's URL + +### "JWT verification failed" +- Verify `SUPABASE_JWT_SECRET` matches the one in your Supabase dashboard +- Ensure there are no trailing spaces in your environment variables + +### OAuth popup closes without logging in +- Check browser console for errors +- Verify the OAuth provider is properly enabled in Supabase +- Ensure redirect URLs are correctly configured in both the OAuth provider and Supabase diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90d2322..b4307e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@supabase/supabase-js": "^2.99.3", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "5.91.3", "react": "19.2.4", @@ -2148,6 +2149,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.99.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.3.tgz", + "integrity": "sha512-vMEVLA1kGGYd/kdsJSwtjiFUZM1nGfrz2DWmgMBZtocV48qL+L2+4QpIkueXyBEumMQZFEyhz57i/5zGHjvdBw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.99.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.3.tgz", + "integrity": "sha512-6tk2zrcBkzKaaBXPOG5nshn30uJNFGOH9LxOnE8i850eQmsX+jVm7vql9kTPyvUzEHwU4zdjSOkXS9M+9ukMVA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.99.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.3.tgz", + "integrity": "sha512-8HxEf+zNycj7Z8+ONhhlu+7J7Ha+L6weyCtdEeK2mN5OWJbh6n4LPU4iuJ5UlCvvNnbSXMoutY7piITEEAgl2g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.99.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.3.tgz", + "integrity": "sha512-c1azgZ2nZPczbY5k5u5iFrk1InpxN81IvNE+UBAkjrBz3yc5ALLJNkeTQwbJZT4PZBuYXEzqYGLMuh9fdTtTMg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.99.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.3.tgz", + "integrity": "sha512-lOfIm4hInNcd8x0i1LWphnLKxec42wwbjs+vhaVAvR801Vda0UAMbTooUY6gfqgQb8v29GofqKuQMMTAsl6w/w==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.99.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.3.tgz", + "integrity": "sha512-GuPbzoEaI51AkLw9VGhLNvnzw4PHbS3p8j2/JlvLeZNQMKwZw4aEYQIDBRtFwL5Nv7/275n9m4DHtakY8nCvgg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.99.3", + "@supabase/functions-js": "2.99.3", + "@supabase/postgrest-js": "2.99.3", + "@supabase/realtime-js": "2.99.3", + "@supabase/storage-js": "2.99.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2735,12 +2816,17 @@ "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2766,6 +2852,15 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3584,6 +3679,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5778,7 +5882,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -6155,6 +6258,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 31961b1..2421d5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@supabase/supabase-js": "^2.99.3", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "5.91.3", "react": "19.2.4", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61a4d46..7212b17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,14 +2,17 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { Layout } from './components' import { AdminLayout } from './components/admin' import { + AuthCallback, GenlockeDetail, GenlockeList, Home, JournalEntryPage, + Login, NewGenlocke, NewRun, RunList, RunEncounters, + Signup, Stats, } from './pages' import { @@ -28,6 +31,9 @@ function App() { }> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9974ad2..e1f286d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { supabase } from '../lib/supabase' + const API_BASE = import.meta.env['VITE_API_URL'] ?? '' export class ApiError extends Error { @@ -10,11 +12,21 @@ export class ApiError extends Error { } } +async function getAuthHeaders(): Promise> { + const { data } = await supabase.auth.getSession() + if (data.session?.access_token) { + return { Authorization: `Bearer ${data.session.access_token}` } + } + return {} +} + async function request(path: string, options?: RequestInit): Promise { + const authHeaders = await getAuthHeaders() const res = await fetch(`${API_BASE}/api/v1${path}`, { ...options, headers: { 'Content-Type': 'application/json', + ...authHeaders, ...options?.headers, }, }) diff --git a/frontend/src/api/journal.ts b/frontend/src/api/journal.ts index 0a7a7d1..154fb67 100644 --- a/frontend/src/api/journal.ts +++ b/frontend/src/api/journal.ts @@ -5,10 +5,7 @@ import type { UpdateJournalEntryInput, } from '../types/journal' -export function getJournalEntries( - runId: number, - bossResultId?: number -): Promise { +export function getJournalEntries(runId: number, bossResultId?: number): Promise { const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : '' return api.get(`/runs/${runId}/journal${params}`) } diff --git a/frontend/src/api/moves.ts b/frontend/src/api/moves.ts new file mode 100644 index 0000000..1adb215 --- /dev/null +++ b/frontend/src/api/moves.ts @@ -0,0 +1,30 @@ +import { api } from './client' +import type { MoveRef, AbilityRef } from '../types/game' + +export interface PaginatedMoves { + items: MoveRef[] + total: number + limit: number + offset: number +} + +export interface PaginatedAbilities { + items: AbilityRef[] + total: number + limit: number + offset: number +} + +export function searchMoves(search: string, limit = 20): Promise { + const params = new URLSearchParams() + if (search) params.set('search', search) + params.set('limit', String(limit)) + return api.get(`/moves?${params}`) +} + +export function searchAbilities(search: string, limit = 20): Promise { + const params = new URLSearchParams() + if (search) params.set('search', search) + params.set('limit', String(limit)) + return api.get(`/abilities?${params}`) +} diff --git a/frontend/src/components/BossDefeatModal.tsx b/frontend/src/components/BossDefeatModal.tsx index 2751a2f..b294dbe 100644 --- a/frontend/src/components/BossDefeatModal.tsx +++ b/frontend/src/components/BossDefeatModal.tsx @@ -1,9 +1,15 @@ import { type FormEvent, useMemo, useState } from 'react' -import type { BossBattle, CreateBossResultInput } from '../types/game' +import type { + BossBattle, + BossResultTeamMemberInput, + CreateBossResultInput, + EncounterDetail, +} from '../types/game' import { ConditionBadge } from './ConditionBadge' interface BossDefeatModalProps { boss: BossBattle + aliveEncounters: EncounterDetail[] onSubmit: (data: CreateBossResultInput) => void onClose: () => void isPending?: boolean @@ -17,14 +23,43 @@ function matchVariant(labels: string[], starterName?: string | null): string | n return matches.length === 1 ? (matches[0] ?? null) : null } +interface TeamSelection { + encounterId: number + level: number +} + export function BossDefeatModal({ boss, + aliveEncounters, onSubmit, onClose, isPending, starterName, }: BossDefeatModalProps) { + const [selectedTeam, setSelectedTeam] = useState>(new Map()) + const toggleTeamMember = (enc: EncounterDetail) => { + setSelectedTeam((prev) => { + const next = new Map(prev) + if (next.has(enc.id)) { + next.delete(enc.id) + } else { + next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 }) + } + return next + }) + } + + const updateLevel = (encounterId: number, level: number) => { + setSelectedTeam((prev) => { + const next = new Map(prev) + const existing = next.get(encounterId) + if (existing) { + next.set(encounterId, { ...existing, level }) + } + return next + }) + } const variantLabels = useMemo(() => { const labels = new Set() for (const bp of boss.pokemon) { @@ -52,10 +87,12 @@ export function BossDefeatModal({ const handleSubmit = (e: FormEvent) => { e.preventDefault() + const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values()) onSubmit({ bossBattleId: boss.id, result: 'won', attempts: 1, + team, }) } @@ -92,18 +129,93 @@ export function BossDefeatModal({
{[...displayedPokemon] .sort((a, b) => a.order - b.order) - .map((bp) => ( -
- {bp.pokemon.spriteUrl ? ( - {bp.pokemon.name} + .map((bp) => { + const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean) + return ( +
+ {bp.pokemon.spriteUrl ? ( + {bp.pokemon.name} + ) : ( +
+ )} + {bp.pokemon.name} + Lv.{bp.level} + + {bp.ability && ( + {bp.ability.name} + )} + {bp.heldItem && ( + {bp.heldItem} + )} + {moves.length > 0 && ( +
+ {moves.map((m) => m!.name).join(', ')} +
+ )} +
+ ) + })} +
+
+ )} + + {/* Team selection */} + {aliveEncounters.length > 0 && ( +
+

Your team (optional)

+
+ {aliveEncounters.map((enc) => { + const isSelected = selectedTeam.has(enc.id) + const selection = selectedTeam.get(enc.id) + const displayPokemon = enc.currentPokemon ?? enc.pokemon + return ( +
toggleTeamMember(enc)} + > + toggleTeamMember(enc)} + className="sr-only" + /> + {displayPokemon.spriteUrl ? ( + {displayPokemon.name} ) : ( -
+
)} - {bp.pokemon.name} - Lv.{bp.level} - +
+

+ {enc.nickname ?? displayPokemon.name} +

+ {isSelected && ( + { + e.stopPropagation() + updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1) + }} + onClick={(e) => e.stopPropagation()} + className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1" + placeholder="Lv" + /> + )} +
- ))} + ) + })}
)} diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx index 12fb90c..cd14506 100644 --- a/frontend/src/components/Layout.test.tsx +++ b/frontend/src/components/Layout.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import { Layout } from './Layout' +import { AuthProvider } from '../contexts/AuthContext' vi.mock('../hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }), @@ -10,7 +11,9 @@ vi.mock('../hooks/useTheme', () => ({ function renderLayout(initialPath = '/') { return render( - + + + ) } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 920ed40..689fc76 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Link, Outlet, useLocation } from 'react-router-dom' import { useTheme } from '../hooks/useTheme' +import { useAuth } from '../contexts/AuthContext' const navLinks = [ { to: '/runs/new', label: 'New Run' }, @@ -71,6 +72,67 @@ function ThemeToggle() { ) } +function UserMenu({ onAction }: { onAction?: () => void }) { + const { user, loading, signOut } = useAuth() + const [open, setOpen] = useState(false) + + if (loading) { + return
+ } + + if (!user) { + return ( + + Sign in + + ) + } + + const email = user.email ?? '' + const initials = email.charAt(0).toUpperCase() + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+
+

{email}

+
+
+ +
+
+ + )} +
+ ) +} + export function Layout() { const [menuOpen, setMenuOpen] = useState(false) const location = useLocation() @@ -103,6 +165,7 @@ export function Layout() { ))} +
{/* Mobile hamburger */}
@@ -149,6 +212,9 @@ export function Layout() { {link.label} ))} +
+ setMenuOpen(false)} /> +
)} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..37f79f4 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,21 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + const location = useLocation() + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return + } + + return <>{children} +} diff --git a/frontend/src/components/admin/AbilitySelector.tsx b/frontend/src/components/admin/AbilitySelector.tsx new file mode 100644 index 0000000..0e493be --- /dev/null +++ b/frontend/src/components/admin/AbilitySelector.tsx @@ -0,0 +1,69 @@ +import { useState, useRef, useEffect } from 'react' +import { useSearchAbilities } from '../../hooks/useMoves' + +interface AbilitySelectorProps { + label: string + selectedId: number | null + initialName?: string + onChange: (id: number | null, name: string) => void +} + +export function AbilitySelector({ + label, + selectedId, + initialName, + onChange, +}: AbilitySelectorProps) { + const [search, setSearch] = useState(initialName ?? '') + const [open, setOpen] = useState(false) + const ref = useRef(null) + const { data } = useSearchAbilities(search) + const abilities = data?.items ?? [] + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + return ( +
+ + { + setSearch(e.target.value) + setOpen(true) + if (!e.target.value) onChange(null, '') + }} + onFocus={() => search && setOpen(true)} + placeholder="Search ability..." + className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default" + /> + {open && abilities.length > 0 && ( +
    + {abilities.map((a) => ( +
  • { + onChange(a.id, a.name) + setSearch(a.name) + setOpen(false) + }} + className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${ + a.id === selectedId ? 'bg-accent-900/30' : '' + }`} + > + {a.name} +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/admin/BossTeamEditor.tsx b/frontend/src/components/admin/BossTeamEditor.tsx index c9395db..a60bb2f 100644 --- a/frontend/src/components/admin/BossTeamEditor.tsx +++ b/frontend/src/components/admin/BossTeamEditor.tsx @@ -1,8 +1,38 @@ import { type FormEvent, useState } from 'react' import { PokemonSelector } from './PokemonSelector' +import { MoveSelector } from './MoveSelector' +import { AbilitySelector } from './AbilitySelector' import type { BossBattle } from '../../types/game' import type { BossPokemonInput } from '../../types/admin' +const NATURES = [ + 'Hardy', + 'Lonely', + 'Brave', + 'Adamant', + 'Naughty', + 'Bold', + 'Docile', + 'Relaxed', + 'Impish', + 'Lax', + 'Timid', + 'Hasty', + 'Serious', + 'Jolly', + 'Naive', + 'Modest', + 'Mild', + 'Quiet', + 'Bashful', + 'Rash', + 'Calm', + 'Gentle', + 'Sassy', + 'Careful', + 'Quirky', +] + interface BossTeamEditorProps { boss: BossBattle onSave: (team: BossPokemonInput[]) => void @@ -15,6 +45,19 @@ interface PokemonSlot { pokemonName: string level: string order: number + // Detail fields + abilityId: number | null + abilityName: string + heldItem: string + nature: string + move1Id: number | null + move1Name: string + move2Id: number | null + move2Name: string + move3Id: number | null + move3Name: string + move4Id: number | null + move4Name: string } interface Variant { @@ -22,6 +65,27 @@ interface Variant { pokemon: PokemonSlot[] } +function createEmptySlot(order: number): PokemonSlot { + return { + pokemonId: null, + pokemonName: '', + level: '', + order, + abilityId: null, + abilityName: '', + heldItem: '', + nature: '', + move1Id: null, + move1Name: '', + move2Id: null, + move2Name: '', + move3Id: null, + move3Name: '', + move4Id: null, + move4Name: '', + } +} + function groupByVariant(boss: BossBattle): Variant[] { const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order) const map = new Map() @@ -34,25 +98,30 @@ function groupByVariant(boss: BossBattle): Variant[] { pokemonName: bp.pokemon.name, level: String(bp.level), order: bp.order, + abilityId: bp.abilityId, + abilityName: bp.ability?.name ?? '', + heldItem: bp.heldItem ?? '', + nature: bp.nature ?? '', + move1Id: bp.move1Id, + move1Name: bp.move1?.name ?? '', + move2Id: bp.move2Id, + move2Name: bp.move2?.name ?? '', + move3Id: bp.move3Id, + move3Name: bp.move3?.name ?? '', + move4Id: bp.move4Id, + move4Name: bp.move4?.name ?? '', }) } if (map.size === 0) { - return [ - { - label: null, - pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }], - }, - ] + return [{ label: null, pokemon: [createEmptySlot(1)] }] } const variants: Variant[] = [] - // null (default) first if (map.has(null)) { variants.push({ label: null, pokemon: map.get(null)! }) map.delete(null) } - // Then alphabetical const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? '')) for (const [label, pokemon] of remaining) { variants.push({ label, pokemon }) @@ -65,9 +134,19 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit const [activeTab, setActiveTab] = useState(0) const [newVariantName, setNewVariantName] = useState('') const [showAddVariant, setShowAddVariant] = useState(false) + const [expandedSlots, setExpandedSlots] = useState>(new Set()) const activeVariant = variants[activeTab] ?? variants[0] + const toggleExpanded = (key: string) => { + setExpandedSlots((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => { setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v))) } @@ -75,15 +154,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit const addSlot = () => { updateVariant(activeTab, (v) => ({ ...v, - pokemon: [ - ...v.pokemon, - { - pokemonId: null, - pokemonName: '', - level: '', - order: v.pokemon.length + 1, - }, - ], + pokemon: [...v.pokemon, createEmptySlot(v.pokemon.length + 1)], })) } @@ -96,10 +167,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit })) } - const updateSlot = (index: number, field: string, value: number | string | null) => { + const updateSlot = (index: number, updates: Partial) => { updateVariant(activeTab, (v) => ({ ...v, - pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)), + pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, ...updates } : item)), })) } @@ -107,13 +178,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit const name = newVariantName.trim() if (!name) return if (variants.some((v) => v.label === name)) return - setVariants((prev) => [ - ...prev, - { - label: name, - pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }], - }, - ]) + setVariants((prev) => [...prev, { label: name, pokemon: [createEmptySlot(1)] }]) setActiveTab(variants.length) setNewVariantName('') setShowAddVariant(false) @@ -141,6 +206,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit level: Number(p.level), order: i + 1, conditionLabel, + abilityId: p.abilityId, + heldItem: p.heldItem || null, + nature: p.nature || null, + move1Id: p.move1Id, + move2Id: p.move2Id, + move3Id: p.move3Id, + move4Id: p.move4Id, }) } } @@ -150,7 +222,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit return (
-
+

{boss.name}'s Team

@@ -209,11 +281,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40" autoFocus /> - -
- ))} + {/* Main row: Pokemon + Level */} +
+
+ updateSlot(index, { pokemonId: id })} + /> +
+
+ + updateSlot(index, { level: e.target.value })} + className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default" + /> +
+ + +
+ + {/* Expandable details */} + {isExpanded && ( +
+ {/* Row 1: Ability, Held Item, Nature */} +
+ + updateSlot(index, { abilityId: id, abilityName: name }) + } + /> +
+ + updateSlot(index, { heldItem: e.target.value })} + placeholder="e.g. Leftovers" + className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default" + /> +
+
+ + +
+
+ + {/* Row 2: Moves */} +
+ + updateSlot(index, { move1Id: id, move1Name: name }) + } + /> + + updateSlot(index, { move2Id: id, move2Name: name }) + } + /> + + updateSlot(index, { move3Id: id, move3Name: name }) + } + /> + + updateSlot(index, { move4Id: id, move4Name: name }) + } + /> +
+
+ )} +
+ ) + })} {activeVariant && activeVariant.pokemon.length < 6 && ( + + +
+
+
+
+
+ Or continue with +
+
+ +
+ + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ) +} diff --git a/frontend/src/pages/NewGenlocke.tsx b/frontend/src/pages/NewGenlocke.tsx index 5252454..dcbcf73 100644 --- a/frontend/src/pages/NewGenlocke.tsx +++ b/frontend/src/pages/NewGenlocke.tsx @@ -115,8 +115,8 @@ export function NewGenlocke() { // In preset modes, filter out regions already used. const availableRegions = preset === 'custom' - ? regions ?? [] - : regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? [] + ? (regions ?? []) + : (regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []) const usedRegionNames = new Set(legs.map((l) => l.region)) diff --git a/frontend/src/pages/NewRun.tsx b/frontend/src/pages/NewRun.tsx index e876da0..e92cb64 100644 --- a/frontend/src/pages/NewRun.tsx +++ b/frontend/src/pages/NewRun.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { GameGrid, RulesConfiguration, StepIndicator } from '../components' import { useGames, useGameRoutes } from '../hooks/useGames' import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns' -import type { Game, NuzlockeRules } from '../types' +import type { Game, NuzlockeRules, RunVisibility } from '../types' import { DEFAULT_RULES } from '../types' import { RULE_DEFINITIONS } from '../types/rules' @@ -21,6 +21,7 @@ export function NewRun() { const [rules, setRules] = useState(DEFAULT_RULES) const [runName, setRunName] = useState('') const [namingScheme, setNamingScheme] = useState(null) + const [visibility, setVisibility] = useState('public') const { data: routes } = useGameRoutes(selectedGame?.id ?? null) const hiddenRules = useMemo(() => { @@ -46,7 +47,7 @@ export function NewRun() { const handleCreate = () => { if (!selectedGame) return createRun.mutate( - { gameId: selectedGame.id, name: runName, rules, namingScheme }, + { gameId: selectedGame.id, name: runName, rules, namingScheme, visibility }, { onSuccess: (data) => navigate(`/runs/${data.id}`) } ) } @@ -195,6 +196,29 @@ export function NewRun() {
)} +
+ + +

+ {visibility === 'private' + ? 'Only you will be able to see this run' + : 'Anyone can view this run'} +

+
+

Summary

@@ -223,6 +247,10 @@ export function NewRun() { : 'None'}
+
+
Visibility
+
{visibility}
+
diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index 37fb3a9..b8bd8c5 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -1,10 +1,18 @@ import { useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns' import { useGameRoutes } from '../hooks/useGames' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' -import { CustomRulesDisplay, StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components' -import type { RunStatus, EncounterDetail } from '../types' +import { + CustomRulesDisplay, + StatCard, + PokemonCard, + RuleBadges, + StatusChangeModal, + EndRunModal, +} from '../components' +import type { RunStatus, EncounterDetail, RunVisibility } from '../types' type TeamSortKey = 'route' | 'level' | 'species' | 'dex' @@ -49,6 +57,7 @@ export function RunDashboard() { const runIdNum = Number(runId) const { data: run, isLoading, error } = useRun(runIdNum) const { data: routes } = useGameRoutes(run?.gameId ?? null) + const { user } = useAuth() const createEncounter = useCreateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum) const updateRun = useUpdateRun(runIdNum) @@ -57,6 +66,9 @@ export function RunDashboard() { const [showEndRun, setShowEndRun] = useState(false) const [teamSort, setTeamSort] = useState('route') + const isOwner = user && run?.owner?.id === user.id + const canEdit = isOwner || !run?.owner + const encounters = run?.encounters ?? [] const alive = useMemo( () => @@ -190,11 +202,31 @@ export function RunDashboard() {
+ {/* Visibility */} + {canEdit && ( +
+

Visibility

+ +

+ {run.visibility === 'private' + ? 'Only you can see this run' + : 'Anyone can view this run'} +

+
+ )} + {/* Naming Scheme */} {namingCategories && namingCategories.length > 0 && (

Naming Scheme

- {isActive ? ( + {isActive && canEdit ? ( setTeamSort(e.target.value as TeamSortKey)} - className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary" - > - - - - - - )} -
- {showTeam && ( - <> - {alive.length > 0 && ( -
- {alive.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined} + {(alive.length > 0 || dead.length > 0) && ( +
+
+
- )} - {dead.length > 0 && ( + + + {showTeam && alive.length > 1 && ( + + )} +
+ {showTeam && ( <> -

Graveyard

-
- {dead.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined} - /> - ))} -
+ {alive.length > 0 && ( +
+ {alive.map((enc) => ( + setSelectedTeamEncounter(enc) : undefined} + /> + ))} +
+ )} + {dead.length > 0 && ( + <> +

Graveyard

+
+ {dead.map((enc) => ( + setSelectedTeamEncounter(enc) : undefined} + /> + ))} +
+ + )} )} - +
)} -
- )} - {/* Shiny Box */} - {run.rules?.shinyClause && shinyEncounters.length > 0 && ( -
- setSelectedTeamEncounter(enc) : undefined} - /> -
- )} - - {/* Transfer Encounters */} - {transferEncounters.length > 0 && ( -
-

Transferred Pokemon

-
- {transferEncounters.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined} + {/* Shiny Box */} + {run.rules?.shinyClause && shinyEncounters.length > 0 && ( +
+ setSelectedTeamEncounter(enc) : undefined} /> +
+ )} + + {/* Transfer Encounters */} + {transferEncounters.length > 0 && ( +
+

Transferred Pokemon

+
+ {transferEncounters.map((enc) => ( + setSelectedTeamEncounter(enc) : undefined} + /> + ))} +
+
+ )} + + {/* Progress bar */} +
+
+
+

Encounters

+ {isActive && completedCount < totalLocations && ( + + )} +
+ + {completedCount} / {totalLocations} locations + +
+
+
0 ? (completedCount / totalLocations) * 100 : 0}%`, + }} + /> +
+
+ + {/* Filter tabs */} +
+ {( + [ + { key: 'all', label: 'All' }, + { key: 'none', label: 'Unvisited' }, + { key: 'caught', label: 'Caught' }, + { key: 'fainted', label: 'Fainted' }, + { key: 'missed', label: 'Missed' }, + ] as const + ).map(({ key, label }) => ( + ))}
-
- )} - {/* Progress bar */} -
-
-
-

Encounters

- {isActive && completedCount < totalLocations && ( - + {/* Route list */} +
+ {filteredRoutes.length === 0 && ( +

+ {filter === 'all' + ? 'Click a route to log your first encounter' + : 'No routes match this filter — try a different one'} +

)} -
- - {completedCount} / {totalLocations} locations - -
-
-
0 ? (completedCount / totalLocations) * 100 : 0}%`, - }} - /> -
-
+ {filteredRoutes.map((route) => { + // Collect all route IDs to check for boss cards after + const routeIds: number[] = + route.children.length > 0 + ? [route.id, ...route.children.map((c) => c.id)] + : [route.id] - {/* Filter tabs */} -
- {( - [ - { key: 'all', label: 'All' }, - { key: 'none', label: 'Unvisited' }, - { key: 'caught', label: 'Caught' }, - { key: 'fainted', label: 'Fainted' }, - { key: 'missed', label: 'Missed' }, - ] as const - ).map(({ key, label }) => ( - - ))} -
+ // Find boss battles positioned after this route (or any of its children) + const bossesHere: BossBattle[] = [] + for (const rid of routeIds) { + const b = bossesAfterRoute.get(rid) + if (b) bossesHere.push(...b) + } - {/* Route list */} -
- {filteredRoutes.length === 0 && ( -

- {filter === 'all' - ? 'Click a route to log your first encounter' - : 'No routes match this filter — try a different one'} -

- )} - {filteredRoutes.map((route) => { - // Collect all route IDs to check for boss cards after - const routeIds: number[] = - route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id] - - // Find boss battles positioned after this route (or any of its children) - const bossesHere: BossBattle[] = [] - for (const rid of routeIds) { - const b = bossesAfterRoute.get(rid) - if (b) bossesHere.push(...b) - } - - const routeElement = - route.children.length > 0 ? ( - toggleGroup(route.id)} - onRouteClick={handleRouteClick} - filter={filter} - pinwheelClause={pinwheelClause} - /> - ) : ( - (() => { - const encounter = encounterByRoute.get(route.id) - const giftEncounter = giftEncounterByRoute.get(route.id) - const displayEncounter = encounter ?? giftEncounter - const rs = getRouteStatus(displayEncounter) - const si = statusIndicator[rs] - - return ( - - ) - })() - ) - - return ( -
- {routeElement} - {/* Boss battle cards after this route */} - {bossesHere.map((boss) => { - const isDefeated = defeatedBossIds.has(boss.id) - const sectionAfter = sectionDividerAfterBoss.get(boss.id) - const bossTypeLabel: Record = { - gym_leader: 'Gym Leader', - elite_four: 'Elite Four', - champion: 'Champion', - rival: 'Rival', - evil_team: 'Evil Team', - kahuna: 'Kahuna', - totem: 'Totem', - other: 'Boss', - } - const bossTypeColors: Record = { - gym_leader: 'border-yellow-600', - elite_four: 'border-purple-600', - champion: 'border-red-600', - rival: 'border-blue-600', - evil_team: 'border-gray-400', - kahuna: 'border-orange-600', - totem: 'border-teal-600', - other: 'border-gray-500', - } - - const isBossExpanded = expandedBosses.has(boss.id) - const toggleBoss = () => { - setExpandedBosses((prev) => { - const next = new Set(prev) - if (next.has(boss.id)) next.delete(boss.id) - else next.add(boss.id) - return next - }) - } - - return ( -
-
-
-
- - - - {boss.spriteUrl && ( - {boss.name} - )} -
-
- - {boss.name} - - - {bossTypeLabel[boss.bossType] ?? boss.bossType} - - {boss.specialtyType && }
-

- {boss.location} · Level Cap: {boss.levelCap} -

-
+ ) : ( + route.encounterMethods.length > 0 && ( +
+ {route.encounterMethods.map((m) => ( + + ))} +
+ ) + )}
-
e.stopPropagation()}> - {isDefeated ? ( - - Defeated ✓ - - ) : isActive ? ( - - ) : null} -
-
- {/* Boss pokemon team */} - {isBossExpanded && boss.pokemon.length > 0 && ( - - )} -
- {sectionAfter && ( -
-
- - {sectionAfter} - -
-
- )} -
+ {si.label} + + ) + })() ) - })} -
- ) - })} -
- {/* Encounter Modal */} - {selectedRoute && ( - { - setSelectedRoute(null) - setEditingEncounter(null) - }} - isPending={createEncounter.isPending || updateEncounter.isPending} - useAllPokemon={useAllPokemon} - staticClause={run?.rules?.staticClause ?? true} - allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined} - /> - )} + return ( +
+ {routeElement} + {/* Boss battle cards after this route */} + {bossesHere.map((boss) => { + const isDefeated = defeatedBossIds.has(boss.id) + const sectionAfter = sectionDividerAfterBoss.get(boss.id) + const bossTypeLabel: Record = { + gym_leader: 'Gym Leader', + elite_four: 'Elite Four', + champion: 'Champion', + rival: 'Rival', + evil_team: 'Evil Team', + kahuna: 'Kahuna', + totem: 'Totem', + other: 'Boss', + } + const bossTypeColors: Record = { + gym_leader: 'border-yellow-600', + elite_four: 'border-purple-600', + champion: 'border-red-600', + rival: 'border-blue-600', + evil_team: 'border-gray-400', + kahuna: 'border-orange-600', + totem: 'border-teal-600', + other: 'border-gray-500', + } + + const isBossExpanded = expandedBosses.has(boss.id) + const toggleBoss = () => { + setExpandedBosses((prev) => { + const next = new Set(prev) + if (next.has(boss.id)) next.delete(boss.id) + else next.add(boss.id) + return next + }) + } + + return ( +
+
+
+
+ + + + {boss.spriteUrl && ( + {boss.name} + )} +
+
+ + {boss.name} + + + {bossTypeLabel[boss.bossType] ?? boss.bossType} + + {boss.specialtyType && } +
+

+ {boss.location} · Level Cap: {boss.levelCap} +

+
+
+
e.stopPropagation()}> + {isDefeated ? ( + + Defeated ✓ + + ) : isActive ? ( + + ) : null} +
+
+ {/* Boss pokemon team */} + {isBossExpanded && boss.pokemon.length > 0 && ( + + )} + {/* Player team snapshot */} + {isDefeated && (() => { + const result = bossResultByBattleId.get(boss.id) + if (!result || result.team.length === 0) return null + return ( +
+

Your Team

+
+ {result.team.map((tm) => { + const enc = encounterById.get(tm.encounterId) + if (!enc) return null + const dp = enc.currentPokemon ?? enc.pokemon + return ( +
+ {dp.spriteUrl ? ( + {dp.name} + ) : ( +
+ )} + + {enc.nickname ?? dp.name} + + Lv.{tm.level} +
+ ) + })} +
+
+ ) + })()} +
+ {sectionAfter && ( +
+
+ + {sectionAfter} + +
+
+ )} +
+ ) + })} +
+ ) + })} +
+ + {/* Encounter Modal */} + {selectedRoute && ( + { + setSelectedRoute(null) + setEditingEncounter(null) + }} + isPending={createEncounter.isPending || updateEncounter.isPending} + useAllPokemon={useAllPokemon} + staticClause={run?.rules?.staticClause ?? true} + allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined} + /> + )} )} @@ -1633,6 +1707,7 @@ export function RunEncounters() { {selectedBoss && ( { createBossResult.mutate(data, { onSuccess: () => setSelectedBoss(null), diff --git a/frontend/src/pages/RunList.tsx b/frontend/src/pages/RunList.tsx index b28094a..ad0705c 100644 --- a/frontend/src/pages/RunList.tsx +++ b/frontend/src/pages/RunList.tsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react' import { Link } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' import { useRuns } from '../hooks/useRuns' -import type { RunStatus } from '../types' +import type { NuzlockeRun, RunStatus } from '../types' const statusStyles: Record = { active: 'bg-status-active-bg text-status-active border border-status-active/20', @@ -8,22 +10,95 @@ const statusStyles: Record = { failed: 'bg-status-failed-bg text-status-failed border border-status-failed/20', } +function VisibilityBadge({ visibility }: { visibility: 'public' | 'private' }) { + if (visibility === 'private') { + return ( + + Private + + ) + } + return null +} + +function RunCard({ run, isOwned }: { run: NuzlockeRun; isOwned: boolean }) { + return ( + +
+
+
+

{run.name}

+ {isOwned && } +
+

+ Started{' '} + {new Date(run.startedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + {!isOwned && run.owner?.displayName && ( + · by {run.owner.displayName} + )} +

+
+ + {run.status} + +
+ + ) +} + export function RunList() { const { data: runs, isLoading, error } = useRuns() + const { user, loading: authLoading } = useAuth() + + const { myRuns, publicRuns } = useMemo(() => { + if (!runs) return { myRuns: [], publicRuns: [] } + + if (!user) { + return { myRuns: [], publicRuns: runs } + } + + const owned: NuzlockeRun[] = [] + const others: NuzlockeRun[] = [] + + for (const run of runs) { + if (run.owner?.id === user.id) { + owned.push(run) + } else { + others.push(run) + } + } + + return { myRuns: owned, publicRuns: others } + }, [runs, user]) + + const showLoading = isLoading || authLoading return (
-

Your Runs

- - Start New Run - +

+ {user ? 'Nuzlocke Runs' : 'Public Runs'} +

+ {user && ( + + Start New Run + + )}
- {isLoading && ( + {showLoading && (
@@ -35,49 +110,56 @@ export function RunList() {
)} - {runs && runs.length === 0 && ( + {!showLoading && runs && runs.length === 0 && (

- No runs yet. Start your first Nuzlocke! + {user ? 'No runs yet. Start your first Nuzlocke!' : 'No public runs available.'}

- - Start New Run - + {user && ( + + Start New Run + + )} + {!user && ( + + Sign In to Create Runs + + )}
)} - {runs && runs.length > 0 && ( -
- {runs.map((run) => ( - -
-
-

{run.name}

-

- Started{' '} - {new Date(run.startedAt).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} -

-
- - {run.status} - + {!showLoading && runs && runs.length > 0 && ( + <> + {user && myRuns.length > 0 && ( +
+

My Runs

+
+ {myRuns.map((run) => ( + + ))}
- - ))} -
+
+ )} + + {publicRuns.length > 0 && ( +
+ {user && myRuns.length > 0 && ( +

Public Runs

+ )} +
+ {publicRuns.map((run) => ( + + ))} +
+
+ )} + )}
) diff --git a/frontend/src/pages/Signup.tsx b/frontend/src/pages/Signup.tsx new file mode 100644 index 0000000..d4a9732 --- /dev/null +++ b/frontend/src/pages/Signup.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +export function Signup() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + const { signUpWithEmail, signInWithGoogle, signInWithDiscord } = useAuth() + const navigate = useNavigate() + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + + setLoading(true) + const { error } = await signUpWithEmail(email, password) + setLoading(false) + + if (error) { + setError(error.message) + } else { + setSuccess(true) + } + } + + async function handleGoogleSignup() { + setError(null) + const { error } = await signInWithGoogle() + if (error) setError(error.message) + } + + async function handleDiscordSignup() { + setError(null) + const { error } = await signInWithDiscord() + if (error) setError(error.message) + } + + if (success) { + return ( +
+
+
+ + + +
+

Check your email

+

+ We've sent a confirmation link to {email}. Click the link to + activate your account. +

+ +
+
+ ) + } + + return ( +
+
+
+

Create an account

+

Start tracking your Nuzlocke runs

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" + /> +
+ + +
+ +
+
+
+
+
+ Or continue with +
+
+ +
+ + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 87b9f0b..3af9b0b 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -1,9 +1,12 @@ +export { AuthCallback } from './AuthCallback' export { GenlockeDetail } from './GenlockeDetail' export { GenlockeList } from './GenlockeList' export { Home } from './Home' export { JournalEntryPage } from './JournalEntryPage' +export { Login } from './Login' export { NewGenlocke } from './NewGenlocke' export { NewRun } from './NewRun' export { RunList } from './RunList' export { RunEncounters } from './RunEncounters' +export { Signup } from './Signup' export { Stats } from './Stats' diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx index 1fd37dc..be6c842 100644 --- a/frontend/src/test/utils.tsx +++ b/frontend/src/test/utils.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, type RenderOptions } from '@testing-library/react' import { type ReactElement } from 'react' import { MemoryRouter } from 'react-router-dom' +import { AuthProvider } from '../contexts/AuthContext' export function createTestQueryClient(): QueryClient { return new QueryClient({ @@ -16,7 +17,9 @@ function AllProviders({ children }: { children: React.ReactNode }) { const queryClient = createTestQueryClient() return ( - {children} + + {children} + ) } diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 55a0abf..4f246be 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -182,6 +182,14 @@ export interface BossPokemonInput { level: number order: number conditionLabel?: string | null + // Detail fields + abilityId?: number | null + heldItem?: string | null + nature?: string | null + move1Id?: number | null + move2Id?: number | null + move3Id?: number | null + move4Id?: number | null } // Genlocke admin diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index d36a571..df3b47a 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -84,6 +84,12 @@ export interface Encounter { } export type RunStatus = 'active' | 'completed' | 'failed' +export type RunVisibility = 'public' | 'private' + +export interface RunOwner { + id: string + displayName: string | null +} export interface NuzlockeRun { id: number @@ -93,6 +99,8 @@ export interface NuzlockeRun { rules: NuzlockeRules hofEncounterIds: number[] | null namingScheme: string | null + visibility: RunVisibility + owner: RunOwner | null startedAt: string completedAt: string | null } @@ -136,6 +144,7 @@ export interface CreateRunInput { name: string rules?: NuzlockeRules namingScheme?: string | null + visibility?: RunVisibility } export interface UpdateRunInput { @@ -144,6 +153,7 @@ export interface UpdateRunInput { rules?: NuzlockeRules hofEncounterIds?: number[] namingScheme?: string | null + visibility?: RunVisibility } export interface CreateEncounterInput { @@ -175,6 +185,16 @@ export type BossType = | 'totem' | 'other' +export interface MoveRef { + id: number + name: string +} + +export interface AbilityRef { + id: number + name: string +} + export interface BossPokemon { id: number pokemonId: number @@ -182,6 +202,19 @@ export interface BossPokemon { order: number conditionLabel: string | null pokemon: Pokemon + // Detail fields + abilityId: number | null + ability: AbilityRef | null + heldItem: string | null + nature: string | null + move1Id: number | null + move2Id: number | null + move3Id: number | null + move4Id: number | null + move1: MoveRef | null + move2: MoveRef | null + move3: MoveRef | null + move4: MoveRef | null } export interface BossBattle { @@ -202,6 +235,12 @@ export interface BossBattle { pokemon: BossPokemon[] } +export interface BossResultTeamMember { + id: number + encounterId: number + level: number +} + export interface BossResult { id: number runId: number @@ -209,12 +248,19 @@ export interface BossResult { result: 'won' | 'lost' attempts: number completedAt: string | null + team: BossResultTeamMember[] +} + +export interface BossResultTeamMemberInput { + encounterId: number + level: number } export interface CreateBossResultInput { bossBattleId: number result: 'won' | 'lost' attempts?: number + team?: BossResultTeamMemberInput[] } // Re-export for convenience