diff --git a/backend/Pipfile b/backend/Pipfile index ecc3364c..4763cf91 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -25,9 +25,10 @@ click = ">=8.1.3" python-multipart = ">=0.0.5" parameterized = ">=0.8.1" sentry-sdk = ">=1.13.0" +fastapi-sso = ">=0.6.4" [dev-packages] -mypy = ">=0.991" +mypy = "1.2.1" black = ">=22.12.0" isort = ">=5.11.4" pylint = ">=2.15.10" diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index 00dedd10..4e032353 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -12,10 +12,11 @@ from alembic import op ${imports if imports else ""} # revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} +revision: str | None = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | None = ${repr(branch_labels)} +depends_on: str | None = ${repr(depends_on)} + def upgrade() -> None: ${upgrades if upgrades else "pass"} diff --git a/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py b/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py new file mode 100644 index 00000000..0d79c6fc --- /dev/null +++ b/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py @@ -0,0 +1,41 @@ +"""Add ON DELETE CASCADE to users_x_clubs + +Revision ID: 274385f2a757 +Revises: +Create Date: 2023-04-15 11:08:57.406407 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = '274385f2a757' +down_revision: str | None = None +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_users_email', table_name='users') + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.drop_constraint('users_x_clubs_user_id_fkey', 'users_x_clubs', type_='foreignkey') + op.drop_constraint('users_x_clubs_club_id_fkey', 'users_x_clubs', type_='foreignkey') + op.create_foreign_key(None, 'users_x_clubs', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'users_x_clubs', 'clubs', ['club_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('users_x_clubs_club_id_fkey', 'users_x_clubs', type_='foreignkey') + op.drop_constraint('users_x_clubs_user_id_fkey', 'users_x_clubs', type_='foreignkey') + op.create_foreign_key( + 'users_x_clubs_club_id_fkey', 'users_x_clubs', 'clubs', ['club_id'], ['id'] + ) + op.create_foreign_key( + 'users_x_clubs_user_id_fkey', 'users_x_clubs', 'users', ['user_id'], ['id'] + ) + op.drop_index(op.f('ix_users_email'), table_name='users') + op.create_index('ix_users_email', 'users', ['email'], unique=False) + # ### end Alembic commands ### diff --git a/backend/bracket/app.py b/backend/bracket/app.py index faba948e..3f236904 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -4,7 +4,7 @@ from starlette.staticfiles import StaticFiles from bracket.config import Environment, config, environment, init_sentry from bracket.database import database, init_db_when_empty -from bracket.routes import auth, clubs, matches, players, rounds, teams, tournaments +from bracket.routes import auth, clubs, matches, players, rounds, teams, tournaments, users init_sentry() @@ -54,3 +54,4 @@ app.include_router(players.router, tags=['players']) app.include_router(rounds.router, tags=['rounds']) app.include_router(matches.router, tags=['matches']) app.include_router(teams.router, tags=['teams']) +app.include_router(users.router, tags=['users']) diff --git a/backend/bracket/config.py b/backend/bracket/config.py index 6c02077e..6157a0ae 100644 --- a/backend/bracket/config.py +++ b/backend/bracket/config.py @@ -39,10 +39,46 @@ class Config(BaseSettings): admin_email: str | None = None admin_password: str | None = None sentry_dsn: str | None = None + allow_insecure_http_sso: bool = False + base_url: str = 'http://localhost:8400' + + +class CIConfig(Config): + allow_insecure_http_sso = False + + class Config: + env_file = 'ci.env' + + +class DevelopmentConfig(Config): + allow_insecure_http_sso = True + + class Config: + env_file = 'dev.env' + + +class ProductionConfig(Config): + allow_insecure_http_sso = False + + class Config: + env_file = 'prod.env' + + +class DemoConfig(Config): + allow_insecure_http_sso = False + + class Config: + env_file = 'demo.env' environment = Environment(os.getenv('ENVIRONMENT', 'DEVELOPMENT')) -config = Config(_env_file=environment.get_env_filepath()) +config: Config + +match environment: + case Environment.CI: + config = CIConfig() # type: ignore[call-arg] + case Environment.DEVELOPMENT: + config = DevelopmentConfig() # type: ignore[call-arg] def init_sentry() -> None: @@ -50,5 +86,5 @@ def init_sentry() -> None: sentry_sdk.init( dsn=config.sentry_dsn, environment=str(environment.value), - with_locals=False, + include_local_variables=False, ) diff --git a/backend/bracket/logic/elo.py b/backend/bracket/logic/elo.py index 5fb60691..04913dfb 100644 --- a/backend/bracket/logic/elo.py +++ b/backend/bracket/logic/elo.py @@ -7,7 +7,8 @@ from pydantic import BaseModel from bracket.database import database from bracket.models.db.round import RoundWithMatches from bracket.schema import players -from bracket.utils.sql import get_all_players_in_tournament, get_rounds_with_matches +from bracket.sql.players import get_all_players_in_tournament +from bracket.sql.rounds import get_rounds_with_matches from bracket.utils.types import assert_some START_ELO: int = 1200 diff --git a/backend/bracket/logic/scheduling/ladder_players_iter.py b/backend/bracket/logic/scheduling/ladder_players_iter.py index fd7b656b..a761e007 100644 --- a/backend/bracket/logic/scheduling/ladder_players_iter.py +++ b/backend/bracket/logic/scheduling/ladder_players_iter.py @@ -9,7 +9,8 @@ from bracket.models.db.match import MatchFilter, SuggestedMatch from bracket.models.db.player import Player from bracket.models.db.round import RoundWithMatches from bracket.models.db.team import TeamWithPlayers -from bracket.utils.sql import get_active_players_in_tournament, get_rounds_with_matches +from bracket.sql.players import get_active_players_in_tournament +from bracket.sql.rounds import get_rounds_with_matches from bracket.utils.types import assert_some diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py index e44fd4ae..c5c93472 100644 --- a/backend/bracket/logic/scheduling/ladder_teams.py +++ b/backend/bracket/logic/scheduling/ladder_teams.py @@ -2,7 +2,8 @@ from fastapi import HTTPException from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter from bracket.models.db.match import MatchFilter, SuggestedMatch -from bracket.utils.sql import get_rounds_with_matches, get_teams_with_members +from bracket.sql.rounds import get_rounds_with_matches +from bracket.sql.teams import get_teams_with_members async def get_possible_upcoming_matches_for_teams( diff --git a/backend/bracket/models/db/club.py b/backend/bracket/models/db/club.py index 13dbf2f5..1efad83e 100644 --- a/backend/bracket/models/db/club.py +++ b/backend/bracket/models/db/club.py @@ -7,3 +7,11 @@ class Club(BaseModelORM): id: int | None = None name: str created: datetime_utc + + +class ClubCreateBody(BaseModelORM): + name: str + + +class ClubUpdateBody(BaseModelORM): + name: str diff --git a/backend/bracket/models/db/player.py b/backend/bracket/models/db/player.py index 28da5436..39c31ea8 100644 --- a/backend/bracket/models/db/player.py +++ b/backend/bracket/models/db/player.py @@ -21,6 +21,10 @@ class Player(BaseModelORM): return self.id if self.id is not None else int(self.created.timestamp()) +class PlayerInDB(Player): + id: int + + class PlayerBody(BaseModelORM): name: str active: bool diff --git a/backend/bracket/models/db/user.py b/backend/bracket/models/db/user.py index 6b9d787e..d44261ae 100644 --- a/backend/bracket/models/db/user.py +++ b/backend/bracket/models/db/user.py @@ -1,4 +1,5 @@ from heliclockter import datetime_utc +from pydantic import BaseModel, constr from bracket.models.db.shared import BaseModelORM @@ -18,6 +19,21 @@ class UserPublic(UserBase): pass +class UserToUpdate(BaseModel): + email: str + name: str + + +class UserPasswordToUpdate(BaseModel): + password: constr(min_length=8, max_length=48) # type: ignore[valid-type] + + +class UserToRegister(BaseModelORM): + email: str + name: str + password: str + + class UserInDB(User): id: int password_hash: str diff --git a/backend/bracket/routes/__init__.py b/backend/bracket/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bracket/routes/auth.py b/backend/bracket/routes/auth.py index 41373c6d..247004e5 100644 --- a/backend/bracket/routes/auth.py +++ b/backend/bracket/routes/auth.py @@ -13,10 +13,10 @@ from bracket.database import database from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserInDB, UserPublic from bracket.schema import tournaments, users +from bracket.sql.users import get_user_access_to_club, get_user_access_to_tournament from bracket.utils.db import fetch_all_parsed, fetch_one_parsed from bracket.utils.security import pwd_context -from bracket.utils.sql import get_user_access_to_tournament -from bracket.utils.types import JsonDict, assert_some +from bracket.utils.types import assert_some router = APIRouter() @@ -26,9 +26,25 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 7 * 24 * 60 # 1 week oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +# def convert_openid(response: dict[str, Any]) -> OpenID: +# """Convert user information returned by OIDC""" +# return OpenID(display_name=response["sub"]) + + +# os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + +# sso = GoogleSSO( +# client_id="test", +# client_secret="secret", +# redirect_uri="http://localhost:8080/sso_callback", +# allow_insecure_http=config.allow_insecure_http_sso, +# ) + + class Token(BaseModel): access_token: str token_type: str + user_id: int class TokenData(BaseModel): @@ -103,6 +119,21 @@ async def user_authenticated_for_tournament( return UserPublic.parse_obj(user.dict()) +async def user_authenticated_for_club( + club_id: int, token: str = Depends(oauth2_scheme) +) -> UserPublic: + user = await check_jwt_and_get_user(token) + + if not user or not await get_user_access_to_club(club_id, assert_some(user.id)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return UserPublic.parse_obj(user.dict()) + + async def user_authenticated_or_public_dashboard( tournament_id: int, request: Request ) -> UserPublic | None: @@ -130,7 +161,7 @@ async def user_authenticated_or_public_dashboard( @router.post("/token", response_model=Token) -async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> JsonDict: +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> Token: user = await authenticate_user(form_data.username, form_data.password) if not user: raise HTTPException( @@ -143,9 +174,30 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends( access_token = create_access_token( data={"user": user.email}, expires_delta=access_token_expires ) - return {"access_token": access_token, "token_type": "bearer"} + return Token(access_token=access_token, token_type='bearer', user_id=user.id) -@router.get("/users/me/", response_model=UserPublic) -async def read_users_me(current_user: UserPublic = Depends(user_authenticated)) -> UserPublic: +# @router.get("/login", summary='SSO login') +# async def sso_login() -> RedirectResponse: +# """Generate login url and redirect""" +# return cast(RedirectResponse, await sso.get_login_redirect()) +# +# +# @router.get("/sso_callback", summary='SSO callback') +# async def sso_callback(request: Request) -> dict[str, Any]: +# """Process login response from OIDC and return user info""" +# user = await sso.verify_and_process(request) +# if user is None: +# raise HTTPException(401, "Failed to fetch user information") +# return { +# "id": user.id, +# "picture": user.picture, +# "display_name": user.display_name, +# "email": user.email, +# "provider": user.provider, +# } + + +@router.get("/me", response_model=UserPublic) +async def get_user_details(current_user: UserPublic = Depends(user_authenticated)) -> UserPublic: return current_user diff --git a/backend/bracket/routes/clubs.py b/backend/bracket/routes/clubs.py index 979b3dcf..ed0e7580 100644 --- a/backend/bracket/routes/clubs.py +++ b/backend/bracket/routes/clubs.py @@ -1,16 +1,37 @@ from fastapi import APIRouter, Depends -from bracket.database import database -from bracket.models.db.club import Club +from bracket.models.db.club import ClubCreateBody, ClubUpdateBody from bracket.models.db.user import UserPublic -from bracket.routes.auth import user_authenticated -from bracket.routes.models import ClubsResponse -from bracket.schema import clubs -from bracket.utils.db import fetch_all_parsed +from bracket.routes.auth import user_authenticated, user_authenticated_for_club +from bracket.routes.models import ClubResponse, ClubsResponse, SuccessResponse +from bracket.sql.clubs import create_club, get_clubs_for_user_id, sql_delete_club, sql_update_club +from bracket.utils.types import assert_some router = APIRouter() @router.get("/clubs", response_model=ClubsResponse) -async def get_clubs(_: UserPublic = Depends(user_authenticated)) -> ClubsResponse: - return ClubsResponse(data=await fetch_all_parsed(database, Club, clubs.select())) +async def get_clubs(user: UserPublic = Depends(user_authenticated)) -> ClubsResponse: + return ClubsResponse(data=await get_clubs_for_user_id(assert_some(user.id))) + + +@router.post("/clubs", response_model=ClubResponse) +async def create_new_club( + club: ClubCreateBody, user: UserPublic = Depends(user_authenticated) +) -> ClubResponse: + return ClubResponse(data=await create_club(club, assert_some(user.id))) + + +@router.delete("/clubs/{club_id}", response_model=SuccessResponse) +async def delete_club( + club_id: int, _: UserPublic = Depends(user_authenticated_for_club) +) -> SuccessResponse: + await sql_delete_club(club_id) + return SuccessResponse() + + +@router.patch("/clubs/{club_id}", response_model=ClubResponse) +async def update_club( + club_id: int, club: ClubUpdateBody, _: UserPublic = Depends(user_authenticated) +) -> ClubResponse: + return ClubResponse(data=await sql_update_club(club_id, club)) diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py index c8ecf1b6..fa067ed4 100644 --- a/backend/bracket/routes/models.py +++ b/backend/bracket/routes/models.py @@ -10,6 +10,7 @@ from bracket.models.db.round import Round, RoundWithMatches from bracket.models.db.team import FullTeamWithPlayers, Team from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic +from bracket.routes.auth import Token DataT = TypeVar('DataT') @@ -26,6 +27,10 @@ class ClubsResponse(DataResponse[list[Club]]): pass +class ClubResponse(DataResponse[Club | None]): + pass + + class TournamentResponse(DataResponse[Tournament]): pass @@ -68,3 +73,7 @@ class SingleTeamResponse(DataResponse[Team]): class UserPublicResponse(DataResponse[UserPublic]): pass + + +class TokenResponse(DataResponse[Token]): + pass diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py index 672154a7..16bf4062 100644 --- a/backend/bracket/routes/players.py +++ b/backend/bracket/routes/players.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from fastapi import APIRouter, Depends from heliclockter import datetime_utc @@ -8,6 +10,7 @@ from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import PlayersResponse, SinglePlayerResponse, SuccessResponse from bracket.schema import players from bracket.utils.db import fetch_all_parsed, fetch_one_parsed +from bracket.utils.types import assert_some router = APIRouter() @@ -20,7 +23,7 @@ async def get_players( ) -> PlayersResponse: query = players.select().where(players.c.tournament_id == tournament_id) if not_in_team: - query = query.where(players.c.team_id == None) + query = query.where(players.c.team_id is None) return PlayersResponse(data=await fetch_all_parsed(database, Player, query)) @@ -41,12 +44,14 @@ async def update_player_by_id( values=player_body.dict(), ) return SinglePlayerResponse( - data=await fetch_one_parsed( - database, - Player, - players.select().where( - (players.c.id == player_id) & (players.c.tournament_id == tournament_id) - ), + data=assert_some( + await fetch_one_parsed( + database, + Player, + players.select().where( + (players.c.id == player_id) & (players.c.tournament_id == tournament_id) + ), + ) ) ) @@ -75,16 +80,18 @@ async def create_player( **player_body.dict(), created=datetime_utc.now(), tournament_id=tournament_id, - elo_score=0, - swiss_score=0, + elo_score=Decimal('0.0'), + swiss_score=Decimal('0.0'), ).dict(), ) return SinglePlayerResponse( - data=await fetch_one_parsed( - database, - Player, - players.select().where( - players.c.id == last_record_id and players.c.tournament_id == tournament_id - ), + data=assert_some( + await fetch_one_parsed( + database, + Player, + players.select().where( + players.c.id == last_record_id and players.c.tournament_id == tournament_id + ), + ) ) ) diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 67a9e448..3f6a33af 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -13,7 +13,7 @@ from bracket.routes.auth import ( from bracket.routes.models import RoundsWithMatchesResponse, SuccessResponse from bracket.routes.util import round_dependency, round_with_matches_dependency from bracket.schema import rounds -from bracket.utils.sql import get_next_round_name, get_rounds_with_matches +from bracket.sql.rounds import get_next_round_name, get_rounds_with_matches router = APIRouter() @@ -24,13 +24,13 @@ async def get_rounds( user: UserPublic = Depends(user_authenticated_or_public_dashboard), no_draft_rounds: bool = False, ) -> RoundsWithMatchesResponse: - rounds = await get_rounds_with_matches( + rounds_ = await get_rounds_with_matches( tournament_id, no_draft_rounds=user is None or no_draft_rounds ) if user is not None: - return RoundsWithMatchesResponse(data=rounds) + return RoundsWithMatchesResponse(data=rounds_) - return RoundsWithMatchesResponse(data=[round_ for round_ in rounds if not round_.is_draft]) + return RoundsWithMatchesResponse(data=[round_ for round_ in rounds_ if not round_.is_draft]) @router.delete("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse) @@ -64,7 +64,7 @@ async def create_round( values=RoundToInsert( created=datetime_utc.now(), tournament_id=tournament_id, - name=await get_next_round_name(database, tournament_id), + name=await get_next_round_name(tournament_id), ).dict(), ) return SuccessResponse() @@ -76,7 +76,7 @@ async def update_round_by_id( round_id: int, round_body: RoundBody, _: UserPublic = Depends(user_authenticated_for_tournament), - round: Round = Depends(round_dependency), + round: Round = Depends(round_dependency), # pylint: disable=redefined-builtin ) -> SuccessResponse: values = {'tournament_id': tournament_id, 'round_id': round_id} query = ''' diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index 4e68b3f9..f1c9f051 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -10,8 +10,9 @@ from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse from bracket.routes.util import team_dependency, team_with_players_dependency from bracket.schema import players_x_teams, teams +from bracket.sql.rounds import get_rounds_with_matches +from bracket.sql.teams import get_team_by_id, get_teams_with_members from bracket.utils.db import fetch_one_parsed -from bracket.utils.sql import get_rounds_with_matches, get_teams_with_members from bracket.utils.types import assert_some router = APIRouter() @@ -31,7 +32,8 @@ async def update_team_members(team_id: int, tournament_id: int, player_ids: list # Remove old members from the team await database.execute( query=players_x_teams.delete().where( - (players_x_teams.c.player_id.not_in(player_ids)) & (players_x_teams.c.team_id == team_id) # type: ignore[attr-defined] + (players_x_teams.c.player_id.not_in(player_ids)) # type: ignore[attr-defined] + & (players_x_teams.c.team_id == team_id) ), ) await recalculate_elo_for_tournament_id(tournament_id) @@ -60,12 +62,14 @@ async def update_team_by_id( await update_team_members(assert_some(team.id), tournament_id, team_body.player_ids) return SingleTeamResponse( - data=await fetch_one_parsed( - database, - Team, - teams.select().where( - (teams.c.id == team.id) & (teams.c.tournament_id == tournament_id) - ), + data=assert_some( + await fetch_one_parsed( + database, + Team, + teams.select().where( + (teams.c.id == team.id) & (teams.c.tournament_id == tournament_id) + ), + ) ) ) @@ -77,17 +81,17 @@ async def delete_team( team: FullTeamWithPlayers = Depends(team_with_players_dependency), ) -> SuccessResponse: rounds = await get_rounds_with_matches(tournament_id, no_draft_rounds=False) - for round in rounds: - if team.id in round.get_team_ids(): + for round_ in rounds: + if team.id in round_.get_team_ids(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Could not delete team that participates in matches in the tournament", + detail="Could not delete team that participates in matches in the tournament", ) if len(team.players): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Could not delete team that still has players in it", + detail="Could not delete team that still has players in it", ) await database.execute( @@ -119,8 +123,7 @@ async def create_team( ).dict(), ) await update_team_members(last_record_id, tournament_id, team_to_insert.player_ids) - return SingleTeamResponse( - data=await fetch_one_parsed( - database, Team, teams.select().where(teams.c.id == last_record_id) - ) - ) + + team_result = await get_team_by_id(last_record_id, tournament_id) + assert team_result is not None + return SingleTeamResponse(data=team_result) diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index 178b72dd..80051675 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -17,8 +17,8 @@ from bracket.routes.auth import ( ) from bracket.routes.models import SuccessResponse, TournamentResponse, TournamentsResponse from bracket.schema import tournaments +from bracket.sql.users import get_user_access_to_club, get_which_clubs_has_user_access_to from bracket.utils.db import fetch_all_parsed, fetch_one_parsed_certain -from bracket.utils.sql import get_user_access_to_club, get_which_clubs_has_user_access_to from bracket.utils.types import assert_some router = APIRouter() diff --git a/backend/bracket/routes/users.py b/backend/bracket/routes/users.py new file mode 100644 index 00000000..070f3879 --- /dev/null +++ b/backend/bracket/routes/users.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, Depends, HTTPException +from heliclockter import datetime_utc, timedelta +from starlette import status + +from bracket.models.db.user import ( + User, + UserPasswordToUpdate, + UserPublic, + UserToRegister, + UserToUpdate, +) +from bracket.routes.auth import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + Token, + create_access_token, + user_authenticated, +) +from bracket.routes.models import SuccessResponse, TokenResponse, UserPublicResponse +from bracket.sql.users import ( + check_whether_email_is_in_use, + create_user, + update_user, + update_user_password, +) +from bracket.utils.security import pwd_context +from bracket.utils.types import assert_some + +router = APIRouter() + + +@router.get("/users/{user_id}", response_model=UserPublicResponse) +async def get_user( + user_id: int, user_public: UserPublic = Depends(user_authenticated) +) -> UserPublicResponse: + if user_public.id != user_id: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, 'Can\'t view details of this user') + + return UserPublicResponse(data=user_public) + + +@router.patch("/users/{user_id}", response_model=SuccessResponse) +async def patch_user( + user_id: int, + user_to_update: UserToUpdate, + user_public: UserPublic = Depends(user_authenticated), +) -> SuccessResponse: + assert user_public.id == user_id + await update_user(assert_some(user_public.id), user_to_update) + return SuccessResponse() + + +@router.patch("/users/{user_id}/password", response_model=SuccessResponse) +async def patch_user_password( + user_id: int, + user_to_update: UserPasswordToUpdate, + user_public: UserPublic = Depends(user_authenticated), +) -> SuccessResponse: + assert user_public.id == user_id + await update_user_password( + assert_some(user_public.id), pwd_context.hash(user_to_update.password) + ) + return SuccessResponse() + + +@router.post("/users/register", response_model=TokenResponse) +async def register_user(user_to_register: UserToRegister) -> TokenResponse: + user = User( + email=user_to_register.email, + password_hash=pwd_context.hash(user_to_register.password), + name=user_to_register.name, + created=datetime_utc.now(), + ) + if await check_whether_email_is_in_use(user.email): + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Email address already in use') + + user_created = await create_user(user) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"user": user_created.email}, expires_delta=access_token_expires + ) + return TokenResponse( + data=Token( + access_token=access_token, token_type='bearer', user_id=assert_some(user_created.id) + ) + ) diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py index 64b8fb68..591e3dd3 100644 --- a/backend/bracket/routes/util.py +++ b/backend/bracket/routes/util.py @@ -6,8 +6,9 @@ from bracket.models.db.match import Match from bracket.models.db.round import Round, RoundWithMatches from bracket.models.db.team import FullTeamWithPlayers, Team from bracket.schema import matches, rounds, teams +from bracket.sql.rounds import get_rounds_with_matches +from bracket.sql.teams import get_teams_with_members from bracket.utils.db import fetch_one_parsed -from bracket.utils.sql import get_rounds_with_matches, get_teams_with_members async def round_dependency(tournament_id: int, round_id: int) -> Round: @@ -27,15 +28,15 @@ async def round_dependency(tournament_id: int, round_id: int) -> Round: async def round_with_matches_dependency(tournament_id: int, round_id: int) -> RoundWithMatches: - rounds = await get_rounds_with_matches(tournament_id, no_draft_rounds=False, round_id=round_id) + rounds_ = await get_rounds_with_matches(tournament_id, no_draft_rounds=False, round_id=round_id) - if len(rounds) < 1: + if len(rounds_) < 1: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Could not find round with id {round_id}", ) - return rounds[0] + return rounds_[0] async def match_dependency(tournament_id: int, match_id: int) -> Match: @@ -73,12 +74,12 @@ async def team_dependency(tournament_id: int, team_id: int) -> Team: async def team_with_players_dependency(tournament_id: int, team_id: int) -> FullTeamWithPlayers: - teams = await get_teams_with_members(tournament_id, team_id=team_id) + teams_with_members = await get_teams_with_members(tournament_id, team_id=team_id) - if len(teams) < 1: + if len(teams_with_members) < 1: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Could not find team with id {team_id}", ) - return teams[0] + return teams_with_members[0] diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index ed8454c5..8d9a5ddc 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -81,7 +81,7 @@ users = Table( 'users', metadata, Column('id', BigInteger, primary_key=True, index=True), - Column('email', String, nullable=False, index=True), + Column('email', String, nullable=False, index=True, unique=True), Column('name', String, nullable=False), Column('password_hash', String, nullable=False), Column('created', DateTimeTZ, nullable=False), @@ -91,8 +91,8 @@ users_x_clubs = Table( 'users_x_clubs', metadata, Column('id', BigInteger, primary_key=True, index=True), - Column('club_id', BigInteger, ForeignKey('clubs.id'), nullable=False), - Column('user_id', BigInteger, ForeignKey('users.id'), nullable=False), + Column('club_id', BigInteger, ForeignKey('clubs.id', ondelete='CASCADE'), nullable=False), + Column('user_id', BigInteger, ForeignKey('users.id', ondelete='CASCADE'), nullable=False), ) players_x_teams = Table( diff --git a/backend/bracket/sql/__init__.py b/backend/bracket/sql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bracket/sql/clubs.py b/backend/bracket/sql/clubs.py new file mode 100644 index 00000000..36c7e2b8 --- /dev/null +++ b/backend/bracket/sql/clubs.py @@ -0,0 +1,77 @@ +from bracket.database import database +from bracket.models.db.club import Club, ClubCreateBody, ClubUpdateBody +from bracket.utils.types import assert_some + + +async def create_club(club: ClubCreateBody, user_id: int) -> Club: + async with database.transaction(): + query = ''' + INSERT INTO clubs (name, created) + VALUES (:name, NOW()) + RETURNING * + ''' + result = await database.fetch_one(query=query, values={'name': club.name}) + if result is None: + raise ValueError('Could not create club') + + club_created = Club.parse_obj(result._mapping) + + query_many_to_many = ''' + INSERT INTO users_x_clubs (club_id, user_id) + VALUES (:club_id, :user_id) + ''' + await database.execute( + query=query_many_to_many, + values={'club_id': assert_some(club_created.id), 'user_id': user_id}, + ) + + return club_created + + +async def sql_update_club(club_id: int, club: ClubUpdateBody) -> Club | None: + query = ''' + UPDATE clubs + SET name = :name + WHERE id = :club_id + RETURNING * + ''' + result = await database.fetch_one(query=query, values={'name': club.name, 'club_id': club_id}) + return Club.parse_obj(result) if result is not None else None + + +async def sql_delete_club(club_id: int) -> None: + query = ''' + DELETE FROM clubs + WHERE id = :club_id + ''' + await database.execute(query=query, values={'club_id': club_id}) + + +async def sql_remove_user_from_club(club_id: int, user_id: int) -> None: + query = ''' + DELETE FROM users_x_clubs + WHERE club_id = :club_id + AND user_id = :user_id + ''' + await database.execute(query=query, values={'club_id': club_id, 'user_id': user_id}) + + +async def get_clubs_for_user_id(user_id: int) -> list[Club]: + query = ''' + SELECT * FROM clubs + JOIN users_x_clubs uxc on clubs.id = uxc.club_id + WHERE uxc.user_id = :user_id + ''' + results = await database.fetch_all(query=query, values={'user_id': user_id}) + return [Club.parse_obj(result._mapping) for result in results] + + +async def get_club_for_user_id(club_id: int, user_id: int) -> Club | None: + query = ''' + SELECT * FROM clubs + JOIN users_x_clubs uxc on clubs.id = uxc.club_id + WHERE uxc.user_id = :user_id + AND club_id = :club_id + ''' + result = await database.fetch_one(query=query, values={'user_id': user_id, 'club_id': club_id}) + return Club.parse_obj(result._mapping) if result is not None else None diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py new file mode 100644 index 00000000..2ecc17bf --- /dev/null +++ b/backend/bracket/sql/players.py @@ -0,0 +1,23 @@ +from bracket.database import database +from bracket.models.db.player import Player + + +async def get_all_players_in_tournament(tournament_id: int) -> list[Player]: + query = ''' + SELECT * + FROM players + WHERE players.tournament_id = :tournament_id + ''' + result = await database.fetch_all(query=query, values={'tournament_id': tournament_id}) + return [Player.parse_obj(x._mapping) for x in result] + + +async def get_active_players_in_tournament(tournament_id: int) -> list[Player]: + query = ''' + SELECT * + FROM players + WHERE players.tournament_id = :tournament_id + AND players.active IS TRUE + ''' + result = await database.fetch_all(query=query, values={'tournament_id': tournament_id}) + return [Player.parse_obj(x._mapping) for x in result] diff --git a/backend/bracket/sql/rounds.py b/backend/bracket/sql/rounds.py new file mode 100644 index 00000000..282a8c2d --- /dev/null +++ b/backend/bracket/sql/rounds.py @@ -0,0 +1,54 @@ +from bracket.database import database +from bracket.models.db.round import RoundWithMatches +from bracket.utils.types import dict_without_none + + +async def get_rounds_with_matches( + tournament_id: int, + no_draft_rounds: bool = False, + round_id: int | None = None, +) -> list[RoundWithMatches]: + draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else '' + round_filter = 'AND rounds.id = :round_id' if round_id is not None else '' + query = f''' + WITH teams_with_players AS ( + SELECT DISTINCT ON (teams.id) + teams.*, + to_json(array_remove(array_agg(p), NULL)) as players + FROM teams + LEFT JOIN players_x_teams pt on pt.team_id = teams.id + LEFT JOIN players p on pt.player_id = p.id + WHERE teams.tournament_id = :tournament_id + GROUP BY teams.id + ), matches_with_teams AS ( + SELECT DISTINCT ON (matches.id) + matches.*, + to_json(t1) as team1, + to_json(t2) as team2 + FROM matches + LEFT JOIN teams_with_players t1 on t1.id = matches.team1_id + LEFT JOIN teams_with_players t2 on t2.id = matches.team2_id + LEFT JOIN rounds r on matches.round_id = r.id + WHERE r.tournament_id = :tournament_id + ) + SELECT rounds.*, to_json(array_agg(m.*)) AS matches FROM rounds + LEFT JOIN matches_with_teams m on rounds.id = m.round_id + WHERE rounds.tournament_id = :tournament_id + {draft_filter} + {round_filter} + GROUP BY rounds.id + ''' + values = dict_without_none({'tournament_id': tournament_id, 'round_id': round_id}) + result = await database.fetch_all(query=query, values=values) + return [RoundWithMatches.parse_obj(x._mapping) for x in result] + + +async def get_next_round_name(tournament_id: int) -> str: + query = ''' + SELECT count(*) FROM rounds + WHERE rounds.tournament_id = :tournament_id + ''' + round_count = int( + await database.fetch_val(query=query, values={'tournament_id': tournament_id}) + ) + return f'Round {round_count + 1}' diff --git a/backend/bracket/sql/teams.py b/backend/bracket/sql/teams.py new file mode 100644 index 00000000..dcadd56b --- /dev/null +++ b/backend/bracket/sql/teams.py @@ -0,0 +1,36 @@ +from bracket.database import database +from bracket.models.db.team import FullTeamWithPlayers, Team +from bracket.utils.types import dict_without_none + + +async def get_team_by_id(team_id: int, tournament_id: int) -> Team | None: + query = ''' + SELECT * + FROM teams + WHERE id = :team_id + AND tournament_id = :tournament_id + ''' + result = await database.fetch_one( + query=query, values={'team_id': team_id, 'tournament_id': tournament_id} + ) + return Team.parse_obj(result._mapping) if result is not None else None + + +async def get_teams_with_members( + tournament_id: int, *, only_active_teams: bool = False, team_id: int | None = None +) -> list[FullTeamWithPlayers]: + active_team_filter = 'AND teams.active IS TRUE' if only_active_teams else '' + team_id_filter = 'AND teams.id = :team_id' if team_id is not None else '' + query = f''' + SELECT teams.*, to_json(array_agg(p.*)) AS players + FROM teams + LEFT JOIN players_x_teams pt on pt.team_id = teams.id + LEFT JOIN players p on pt.player_id = p.id + WHERE teams.tournament_id = :tournament_id + {active_team_filter} + {team_id_filter} + GROUP BY teams.id; + ''' + values = dict_without_none({'tournament_id': tournament_id, 'team_id': team_id}) + result = await database.fetch_all(query=query, values=values) + return [FullTeamWithPlayers.parse_obj(x._mapping) for x in result] diff --git a/backend/bracket/sql/users.py b/backend/bracket/sql/users.py new file mode 100644 index 00000000..a4977ea0 --- /dev/null +++ b/backend/bracket/sql/users.py @@ -0,0 +1,78 @@ +from datetime import datetime + +from bracket.database import database +from bracket.models.db.user import User, UserToUpdate +from bracket.utils.types import assert_some + + +async def get_user_access_to_tournament(tournament_id: int, user_id: int) -> bool: + query = ''' + SELECT DISTINCT t.id + FROM users_x_clubs + JOIN tournaments t ON t.club_id = users_x_clubs.club_id + WHERE user_id = :user_id + ''' + result = await database.fetch_all(query=query, values={'user_id': user_id}) + return tournament_id in {tournament.id for tournament in result} # type: ignore[attr-defined] + + +async def get_which_clubs_has_user_access_to(user_id: int) -> set[int]: + query = ''' + SELECT club_id + FROM users_x_clubs + WHERE user_id = :user_id + ''' + result = await database.fetch_all(query=query, values={'user_id': user_id}) + return {club.club_id for club in result} # type: ignore[attr-defined] + + +async def get_user_access_to_club(club_id: int, user_id: int) -> bool: + return club_id in await get_which_clubs_has_user_access_to(user_id) + + +async def update_user(user_id: int, user: UserToUpdate) -> None: + query = ''' + UPDATE users + SET name = :name, email = :email + WHERE id = :user_id + ''' + await database.execute( + query=query, values={'user_id': user_id, 'name': user.name, 'email': user.email} + ) + + +async def update_user_password(user_id: int, password_hash: str) -> None: + query = ''' + UPDATE users + SET password_hash = :password_hash + WHERE id = :user_id + ''' + await database.execute(query=query, values={'user_id': user_id, 'password_hash': password_hash}) + + +async def create_user(user: User) -> User: + query = ''' + INSERT INTO users (email, name, password_hash, created) + VALUES (:email, :name, :password_hash, :created) + RETURNING * + ''' + result = await database.fetch_one( + query=query, + values={ + 'password_hash': user.password_hash, + 'name': user.name, + 'email': user.email, + 'created': datetime.fromisoformat(user.created.isoformat()), + }, + ) + return User.parse_obj(assert_some(result)._mapping) + + +async def check_whether_email_is_in_use(email: str) -> bool: + query = ''' + SELECT id + FROM users + WHERE email = :email + ''' + result = await database.fetch_one(query=query, values={'email': email}) + return result is not None diff --git a/backend/bracket/utils/__init__.py b/backend/bracket/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bracket/utils/logging.py b/backend/bracket/utils/logging.py index 1acd0dc2..ed99f483 100644 --- a/backend/bracket/utils/logging.py +++ b/backend/bracket/utils/logging.py @@ -4,18 +4,18 @@ from bracket.config import environment def create_logger(level: int) -> logging.Logger: - logFormatter = logging.Formatter(fmt='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s') + log_formatter = logging.Formatter(fmt='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s') - logger = logging.getLogger('bracket') + logger = logging.getLogger('bracket') # pylint: disable=redefined-outer-name logger.setLevel(level) - consoleHandler = logging.StreamHandler() - consoleHandler.setLevel(level) - consoleHandler.setFormatter(logFormatter) - logger.addHandler(consoleHandler) + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(log_formatter) + logger.addHandler(console_handler) return logger logger = create_logger(environment.get_log_level()) -logger.info(f'Current env: {environment.value}') +logger.info('Current env: %s', environment.value) diff --git a/backend/bracket/utils/sql.py b/backend/bracket/utils/sql.py deleted file mode 100644 index cb4d265d..00000000 --- a/backend/bracket/utils/sql.py +++ /dev/null @@ -1,124 +0,0 @@ -from databases import Database - -from bracket.database import database -from bracket.models.db.player import Player -from bracket.models.db.round import RoundWithMatches -from bracket.models.db.team import FullTeamWithPlayers -from bracket.utils.types import dict_without_none - - -async def get_rounds_with_matches( - tournament_id: int, - no_draft_rounds: bool = False, - round_id: int | None = None, -) -> list[RoundWithMatches]: - draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else '' - round_filter = 'AND rounds.id = :round_id' if round_id is not None else '' - query = f''' - WITH teams_with_players AS ( - SELECT DISTINCT ON (teams.id) - teams.*, - to_json(array_remove(array_agg(p), NULL)) as players - FROM teams - LEFT JOIN players_x_teams pt on pt.team_id = teams.id - LEFT JOIN players p on pt.player_id = p.id - WHERE teams.tournament_id = :tournament_id - GROUP BY teams.id - ), matches_with_teams AS ( - SELECT DISTINCT ON (matches.id) - matches.*, - to_json(t1) as team1, - to_json(t2) as team2 - FROM matches - LEFT JOIN teams_with_players t1 on t1.id = matches.team1_id - LEFT JOIN teams_with_players t2 on t2.id = matches.team2_id - LEFT JOIN rounds r on matches.round_id = r.id - WHERE r.tournament_id = :tournament_id - ) - SELECT rounds.*, to_json(array_agg(m.*)) AS matches FROM rounds - LEFT JOIN matches_with_teams m on rounds.id = m.round_id - WHERE rounds.tournament_id = :tournament_id - {draft_filter} - {round_filter} - GROUP BY rounds.id - ''' - values = dict_without_none({'tournament_id': tournament_id, 'round_id': round_id}) - result = await database.fetch_all(query=query, values=values) - return [RoundWithMatches.parse_obj(x._mapping) for x in result] - - -async def get_next_round_name(database: Database, tournament_id: int) -> str: - query = ''' - SELECT count(*) FROM rounds - WHERE rounds.tournament_id = :tournament_id - ''' - round_count = int( - await database.fetch_val(query=query, values={'tournament_id': tournament_id}) - ) - return f'Round {round_count + 1}' - - -async def get_teams_with_members( - tournament_id: int, *, only_active_teams: bool = False, team_id: int | None = None -) -> list[FullTeamWithPlayers]: - active_team_filter = 'AND teams.active IS TRUE' if only_active_teams else '' - team_id_filter = 'AND teams.id = :team_id' if team_id is not None else '' - query = f''' - SELECT teams.*, to_json(array_agg(p.*)) AS players - FROM teams - LEFT JOIN players_x_teams pt on pt.team_id = teams.id - LEFT JOIN players p on pt.player_id = p.id - WHERE teams.tournament_id = :tournament_id - {active_team_filter} - {team_id_filter} - GROUP BY teams.id; - ''' - values = dict_without_none({'tournament_id': tournament_id, 'team_id': team_id}) - result = await database.fetch_all(query=query, values=values) - return [FullTeamWithPlayers.parse_obj(x._mapping) for x in result] - - -async def get_active_players_in_tournament(tournament_id: int) -> list[Player]: - query = f''' - SELECT * - FROM players - WHERE players.tournament_id = :tournament_id - AND players.active IS TRUE - ''' - result = await database.fetch_all(query=query, values={'tournament_id': tournament_id}) - return [Player.parse_obj(x._mapping) for x in result] - - -async def get_all_players_in_tournament(tournament_id: int) -> list[Player]: - query = f''' - SELECT * - FROM players - WHERE players.tournament_id = :tournament_id - ''' - result = await database.fetch_all(query=query, values={'tournament_id': tournament_id}) - return [Player.parse_obj(x._mapping) for x in result] - - -async def get_user_access_to_tournament(tournament_id: int, user_id: int) -> bool: - query = f''' - SELECT DISTINCT t.id - FROM users_x_clubs - JOIN tournaments t ON t.club_id = users_x_clubs.club_id - WHERE user_id = :user_id - ''' - result = await database.fetch_all(query=query, values={'user_id': user_id}) - return tournament_id in {tournament.id for tournament in result} # type: ignore[attr-defined] - - -async def get_which_clubs_has_user_access_to(user_id: int) -> set[int]: - query = f''' - SELECT club_id - FROM users_x_clubs - WHERE user_id = :user_id - ''' - result = await database.fetch_all(query=query, values={'user_id': user_id}) - return {club.club_id for club in result} # type: ignore[attr-defined] - - -async def get_user_access_to_club(club_id: int, user_id: int) -> bool: - return club_id in await get_which_clubs_has_user_access_to(user_id) diff --git a/backend/bracket/utils/types.py b/backend/bracket/utils/types.py index 0e0bc0fe..75a1c22d 100644 --- a/backend/bracket/utils/types.py +++ b/backend/bracket/utils/types.py @@ -33,5 +33,5 @@ def assert_some(result: T | None) -> T: return result -def dict_without_none(input: dict[Any, Any]) -> dict[Any, Any]: - return {k: v for k, v in input.items() if v is not None} +def dict_without_none(input_: dict[Any, Any]) -> dict[Any, Any]: + return {k: v for k, v in input_.items() if v is not None} diff --git a/backend/cli.py b/backend/cli.py old mode 100644 new mode 100755 index d5d9ec36..01b23dcf --- a/backend/cli.py +++ b/backend/cli.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import asyncio import functools from typing import Any diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8f8db562..9960d17d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,10 +22,8 @@ asyncio_mode = 'auto' filterwarnings = [ 'error', 'ignore:The SelectBase.c and SelectBase.columns attributes are deprecated.*:DeprecationWarning', - 'ignore:Sending a large body directly with raw bytes might lock the event loop.*:ResourceWarning', - 'ignore:.*unclosed transport <_SelectorSocketTransport.*:ResourceWarning', - 'ignore:.*unclosed None: + payload = {'name': 'Some Cool Club'} + response = await send_auth_request(HTTPMethod.POST, 'clubs', auth_context, json=payload) + user_id = assert_some(auth_context.user.id) + + clubs = await get_clubs_for_user_id(user_id) + club_id = response['data']['id'] # type: ignore[call-overload] + + # await sql_remove_user_from_club(club_id, user_id) + await sql_delete_club(club_id) + + assert len(clubs) == 2 + assert response['data']['name'] == payload['name'] # type: ignore[call-overload] diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index 46248a0b..203bcb96 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -19,28 +19,27 @@ from tests.integration_tests.sql import assert_row_count_and_clear, inserted_rou async def test_rounds_endpoint( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext, with_auth: bool ) -> None: - async with inserted_team(DUMMY_TEAM1): - async with inserted_round(DUMMY_ROUND1) as round_inserted: - if with_auth: - response = await send_tournament_request(HTTPMethod.GET, 'rounds', auth_context, {}) - else: - response = await send_request( - HTTPMethod.GET, f'tournaments/{auth_context.tournament.id}/rounds' - ) + async with (inserted_team(DUMMY_TEAM1), inserted_round(DUMMY_ROUND1) as round_inserted): + if with_auth: + response = await send_tournament_request(HTTPMethod.GET, 'rounds', auth_context, {}) + else: + response = await send_request( + HTTPMethod.GET, f'tournaments/{auth_context.tournament.id}/rounds' + ) - assert response == { - 'data': [ - { - 'created': DUMMY_MOCK_TIME.isoformat(), - 'id': round_inserted.id, - 'is_active': False, - 'is_draft': False, - 'matches': [], - 'name': 'Round 1', - 'tournament_id': 1, - } - ], - } + assert response == { + 'data': [ + { + 'created': DUMMY_MOCK_TIME.isoformat(), + 'id': round_inserted.id, + 'is_active': False, + 'is_draft': False, + 'matches': [], + 'name': 'Round 1', + 'tournament_id': 1, + } + ], + } async def test_create_round( @@ -57,34 +56,32 @@ async def test_create_round( async def test_delete_round( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - async with inserted_team(DUMMY_TEAM1): - async with inserted_round(DUMMY_ROUND1) as round_inserted: - assert ( - await send_tournament_request( - HTTPMethod.DELETE, f'rounds/{round_inserted.id}', auth_context, {} - ) - == SUCCESS_RESPONSE + async with (inserted_team(DUMMY_TEAM1), inserted_round(DUMMY_ROUND1) as round_inserted): + assert ( + await send_tournament_request( + HTTPMethod.DELETE, f'rounds/{round_inserted.id}', auth_context, {} ) - await assert_row_count_and_clear(rounds, 0) + == SUCCESS_RESPONSE + ) + await assert_row_count_and_clear(rounds, 0) async def test_update_round( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: body = {'name': 'Some new name', 'is_draft': True, 'is_active': False} - async with inserted_team(DUMMY_TEAM1): - async with inserted_round(DUMMY_ROUND1) as round_inserted: - assert ( - await send_tournament_request( - HTTPMethod.PATCH, f'rounds/{round_inserted.id}', auth_context, None, body - ) - == SUCCESS_RESPONSE + async with (inserted_team(DUMMY_TEAM1), inserted_round(DUMMY_ROUND1) as round_inserted): + assert ( + await send_tournament_request( + HTTPMethod.PATCH, f'rounds/{round_inserted.id}', auth_context, None, body ) - patched_round = await fetch_one_parsed_certain( - database, Round, query=rounds.select().where(rounds.c.id == round_inserted.id) - ) - assert patched_round.name == body['name'] - assert patched_round.is_draft == body['is_draft'] - assert patched_round.is_active == body['is_active'] + == SUCCESS_RESPONSE + ) + patched_round = await fetch_one_parsed_certain( + database, Round, query=rounds.select().where(rounds.c.id == round_inserted.id) + ) + assert patched_round.name == body['name'] + assert patched_round.is_draft == body['is_draft'] + assert patched_round.is_active == body['is_active'] - await assert_row_count_and_clear(rounds, 1) + await assert_row_count_and_clear(rounds, 1) diff --git a/backend/tests/integration_tests/api/shared.py b/backend/tests/integration_tests/api/shared.py index 986cd6fd..f9b92fe7 100644 --- a/backend/tests/integration_tests/api/shared.py +++ b/backend/tests/integration_tests/api/shared.py @@ -51,7 +51,7 @@ class UvicornTestServer(uvicorn.Server): async def startup(self, sockets: Optional[Sequence[socket.socket]] = None) -> None: sockets_list = list(sockets) if sockets is not None else sockets - await super().startup(sockets=sockets_list) # type: ignore[arg-type] + await super().startup(sockets=sockets_list) self.config.setup_event_loop() self._startup_done.set() diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index da0727e3..4db75b58 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -26,7 +26,7 @@ from bracket.schema import ( ) from bracket.utils.db import fetch_one_parsed from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_TOURNAMENT -from bracket.utils.types import BaseModelT +from bracket.utils.types import BaseModelT, assert_some from tests.integration_tests.mocks import MOCK_USER, get_mock_token from tests.integration_tests.models import AuthContext @@ -85,7 +85,9 @@ async def inserted_player(player: Player) -> AsyncIterator[Player]: async def inserted_player_in_team(player: Player, team_id: int) -> AsyncIterator[Player]: async with inserted_generic(player, players, Player) as row_inserted: async with inserted_generic( - PlayerXTeam(player_id=row_inserted.id, team_id=team_id), players_x_teams, PlayerXTeam + PlayerXTeam(player_id=assert_some(row_inserted.id), team_id=team_id), + players_x_teams, + PlayerXTeam, ): yield row_inserted @@ -115,7 +117,7 @@ async def inserted_auth_context() -> AsyncIterator[AuthContext]: async with inserted_club(DUMMY_CLUB) as club_inserted: async with inserted_tournament(DUMMY_TOURNAMENT) as tournament_inserted: async with inserted_user_x_club( - UserXClub(user_id=user_inserted.id, club_id=club_inserted.id) + UserXClub(user_id=user_inserted.id, club_id=assert_some(club_inserted.id)) ) as user_x_club_inserted: yield AuthContext( headers=headers, diff --git a/frontend/package.json b/frontend/package.json index 10a12314..071d31c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,12 +19,12 @@ "dependencies": { "@emotion/react": "^11.10.6", "@emotion/server": "^11.10.0", - "@mantine/core": "^5.9.5", - "@mantine/form": "^5.10.0", - "@mantine/hooks": "^5.9.5", - "@mantine/dropzone": "^5.9.5", - "@mantine/next": "^5.10.5", - "@mantine/notifications": "^5.9.5", + "@mantine/core": "^6.0.5", + "@mantine/form": "^6.0.5", + "@mantine/hooks": "^6.0.5", + "@mantine/dropzone": "^6.0.5", + "@mantine/next": "^6.0.5", + "@mantine/notifications": "^6.0.5", "@next/bundle-analyzer": "^13.2.1", "@react-icons/all-files": "^4.1.0", "@tabler/icons": "^1.119.0", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 34df8f6c..df3dd205 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -2,21 +2,21 @@ + width="73.701149mm" + height="73.701828mm" + viewBox="0 0 73.701149 73.701828" + version="1.1" + id="svg5" + inkscape:export-filename="/home/erik/code/bracket/frontend/public/favicon.png" + inkscape:export-xdpi="400.06485" + inkscape:export-ydpi="400.06485" + sodipodi:docname="favicon.svg" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" +> {match.team2_score} + + ); + + if (readOnly) { + return
{bracket}
; + } + + return ( + <> + setOpened(!opened)}> + {bracket} + ); - - if (readOnly) { - return
{bracket}
; - } - - return ( - setOpened(!opened)}> - {bracket} - - ); } diff --git a/frontend/src/components/forms/user.tsx b/frontend/src/components/forms/user.tsx new file mode 100644 index 00000000..ffd07607 --- /dev/null +++ b/frontend/src/components/forms/user.tsx @@ -0,0 +1,84 @@ +import { Button, Tabs, TextInput } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconHash, IconUser } from '@tabler/icons'; +import React from 'react'; + +import { UserInterface } from '../../interfaces/user'; +import { updatePassword, updateUser } from '../../services/user'; +import { PasswordStrength } from '../utils/password'; + +export default function UserForm({ user }: { user: UserInterface }) { + const details_form = useForm({ + initialValues: { + name: user != null ? user.name : '', + email: user != null ? user.email : '', + password: '', + }, + + validate: { + name: (value) => (value !== '' ? null : 'Name cannot be empty'), + email: (value) => (value !== '' ? null : 'Email cannot be empty'), + }, + }); + const password_form = useForm({ + initialValues: { + password: '', + }, + + validate: { + password: (value) => (value.length >= 8 ? null : 'Password too short'), + }, + }); + + return ( + + + }> + Edit details + + }> + Edit password + + {/*}>*/} + {/* Settings*/} + {/**/} + + +
{ + if (user != null) await updateUser(user.id, values); + })} + > + + + + +
+ +
{ + if (user != null) await updatePassword(user.id, values.password); + })} + > + + + +
+
+ ); +} diff --git a/frontend/src/components/modals/club_modal.tsx b/frontend/src/components/modals/club_modal.tsx new file mode 100644 index 00000000..ce006b64 --- /dev/null +++ b/frontend/src/components/modals/club_modal.tsx @@ -0,0 +1,80 @@ +import { Button, Group, Modal, TextInput } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { BiEditAlt } from '@react-icons/all-files/bi/BiEditAlt'; +import { GoPlus } from '@react-icons/all-files/go/GoPlus'; +import { useState } from 'react'; +import { SWRResponse } from 'swr'; + +import { Club } from '../../interfaces/club'; +import { createClub, updateClub } from '../../services/club'; +import SaveButton from '../buttons/save'; + +export default function ClubModal({ + club, + swrClubsResponse, +}: { + club: Club | null; + swrClubsResponse: SWRResponse; +}) { + const is_create_form = club == null; + const operation_text = is_create_form ? 'Create Club' : 'Edit Club'; + const icon = is_create_form ? : ; + const [opened, setOpened] = useState(false); + const modalOpenButton = is_create_form ? ( + + setOpened(true)} + leftIcon={} + title={operation_text} + /> + + ) : ( + + ); + + const form = useForm({ + initialValues: { + name: club == null ? '' : club.name, + }, + + validate: { + name: (value) => (value.length > 0 ? null : 'Name too short'), + }, + }); + + return ( + <> + setOpened(false)} title={operation_text}> +
{ + if (is_create_form) await createClub(values.name); + else await updateClub(club.id, values.name); + await swrClubsResponse.mutate(null); + setOpened(false); + })} + > + + + + +
+ + {modalOpenButton} + + ); +} diff --git a/frontend/src/components/modals/match_modal.tsx b/frontend/src/components/modals/match_modal.tsx index f0630db2..7fbf6a01 100644 --- a/frontend/src/components/modals/match_modal.tsx +++ b/frontend/src/components/modals/match_modal.tsx @@ -67,6 +67,7 @@ export default function MatchModal({ placeholder={`Score of ${match.team2.name}`} {...form.getInputProps('team2_score')} /> + diff --git a/frontend/src/components/modals/team_modal.tsx b/frontend/src/components/modals/team_modal.tsx index b707f4e4..f21b7566 100644 --- a/frontend/src/components/modals/team_modal.tsx +++ b/frontend/src/components/modals/team_modal.tsx @@ -98,6 +98,7 @@ export default function TeamModal({ placeholder="Pick all that you like" searchable limit={20} + mt={12} {...form.getInputProps('player_ids')} /> diff --git a/frontend/src/components/modals/tournament_modal.tsx b/frontend/src/components/modals/tournament_modal.tsx index 25b21977..09e6f3dd 100644 --- a/frontend/src/components/modals/tournament_modal.tsx +++ b/frontend/src/components/modals/tournament_modal.tsx @@ -6,7 +6,7 @@ import assert from 'assert'; import React, { useState } from 'react'; import { SWRResponse } from 'swr'; -import { ClubInterface } from '../../interfaces/club'; +import { Club } from '../../interfaces/club'; import { Tournament } from '../../interfaces/tournament'; import { getBaseApiUrl, getClubs } from '../../services/adapter'; import { createTournament, updateTournament } from '../../services/tournament'; @@ -51,12 +51,12 @@ export default function TournamentModal({ ); const swrClubsResponse: SWRResponse = getClubs(); - const clubs: ClubInterface[] = swrClubsResponse.data != null ? swrClubsResponse.data.data : []; + const clubs: Club[] = swrClubsResponse.data != null ? swrClubsResponse.data.data : []; const form = useForm({ initialValues: { name: tournament == null ? '' : tournament.name, - club_id: tournament == null ? null : tournament.club_id, + club_id: tournament == null ? null : `${tournament.club_id}`, dashboard_public: tournament == null ? true : tournament.dashboard_public, players_can_be_in_multiple_teams: tournament == null ? true : tournament.players_can_be_in_multiple_teams, @@ -76,7 +76,7 @@ export default function TournamentModal({ assert(values.club_id != null); if (is_create_form) { await createTournament( - values.club_id, + parseInt(values.club_id, 10), values.name, values.dashboard_public, values.players_can_be_in_multiple_teams diff --git a/frontend/src/components/navbar/_user.tsx b/frontend/src/components/navbar/_user.tsx index db6c3744..4bc34714 100644 --- a/frontend/src/components/navbar/_user.tsx +++ b/frontend/src/components/navbar/_user.tsx @@ -1,16 +1,16 @@ -import { Navbar, createStyles } from '@mantine/core'; +import { Navbar, createStyles, getStylesRef, rem } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { FaBook } from '@react-icons/all-files/fa/FaBook'; import { FaGithub } from '@react-icons/all-files/fa/FaGithub'; -import { IconLogout } from '@tabler/icons'; +import { IconCategory2, IconLogout, IconUser } from '@tabler/icons'; import { useRouter } from 'next/router'; import React from 'react'; import { getBaseApiUrl } from '../../services/adapter'; -import { performLogout } from '../../services/user'; +import { performLogout } from '../../services/local_storage'; -export const useNavbarStyles = createStyles((theme, _params, getRef) => { - const icon = getRef('icon'); +export const useNavbarStyles = createStyles((theme) => { + const icon = getStylesRef('icon'); return { navbar: { @@ -19,7 +19,7 @@ export const useNavbarStyles = createStyles((theme, _params, getRef) => { title: { textTransform: 'uppercase', - letterSpacing: -0.25, + letterSpacing: rem(-0.25), }, link: { @@ -29,7 +29,7 @@ export const useNavbarStyles = createStyles((theme, _params, getRef) => { textDecoration: 'none', fontSize: theme.fontSizes.sm, color: theme.colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.gray[7], - padding: `${theme.spacing.xs}px ${theme.spacing.sm}px`, + padding: `${theme.spacing.xs} ${theme.spacing.sm}`, borderRadius: theme.radius.sm, fontWeight: 500, @@ -61,10 +61,11 @@ export const useNavbarStyles = createStyles((theme, _params, getRef) => { }, footer: { - borderTop: `1px solid ${ + borderTop: `${rem(1)} solid ${ theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3] }`, - paddingTop: theme.spacing.md, + paddingTop: theme.spacing.xs, + paddingBottom: theme.spacing.xs, }, }; }); @@ -85,21 +86,35 @@ export function User() { } return ( - - - - API documentation - + <> + + + + API documentation + - - - Bracket on GitHub - + + + Bracket on GitHub + + - attemptLogout()}> - - Logout - - + + router.push('/user')}> + + User + + + router.push('/clubs')}> + + Clubs + + + attemptLogout()}> + + Logout + + + ); } diff --git a/frontend/src/components/scheduling/scheduler.tsx b/frontend/src/components/scheduling/scheduler.tsx index fd441ab4..9b9a446c 100644 --- a/frontend/src/components/scheduling/scheduler.tsx +++ b/frontend/src/components/scheduling/scheduler.tsx @@ -1,4 +1,4 @@ -import { Divider, Flex, NumberInput, Radio } from '@mantine/core'; +import { Divider, Flex, Group, NumberInput, Radio } from '@mantine/core'; import { IconListNumbers, IconMedal, IconRepeat } from '@tabler/icons'; import { SWRResponse } from 'swr'; @@ -38,8 +38,10 @@ export default function Scheduler({ onChange={schedulerSettings.setOnlyBehindSchedule} label="Only show teams/players who played less matches" > - - + + + + ; + + const rows = clubs + .sort((p1: Club, p2: Club) => sortTableEntries(p1, p2, tableState)) + .map((club) => ( + + {club.name} + + + { + await deleteClub(club.id); + await swrClubsResponse.mutate(null); + }} + title="Delete Club" + /> + + + )); + + return ( + + + + + Title + + {null} + + + {rows} + + ); +} diff --git a/frontend/src/components/utils/password.tsx b/frontend/src/components/utils/password.tsx new file mode 100644 index 00000000..1578491e --- /dev/null +++ b/frontend/src/components/utils/password.tsx @@ -0,0 +1,82 @@ +import { Box, Center, Group, PasswordInput, Progress, Text } from '@mantine/core'; +import { IconCheck, IconX } from '@tabler/icons'; + +function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) { + return ( + +
+ {meets ? : } + {label} +
+
+ ); +} + +const requirements = [ + { re: /[0-9]/, label: 'Includes number' }, + { re: /[a-z]/, label: 'Includes lowercase letter' }, + { re: /[A-Z]/, label: 'Includes uppercase letter' }, + { re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' }, +]; + +function getStrength(password: string) { + let multiplier = password.length > 5 ? 0 : 1; + + requirements.forEach((requirement) => { + if (!requirement.re.test(password)) { + multiplier += 1; + } + }); + + return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 0); +} + +export function PasswordStrength({ form }: { form: any }) { + const strength = getStrength(form.values.password); + const checks = requirements.map((requirement, index) => ( + + )); + const bars = Array(4) + .fill(0) + .map((_, index) => ( + 0 && index === 0 + ? 100 + : strength >= ((index + 1) / 4) * 100 + ? 100 + : 0 + } + color={strength > 80 ? 'teal' : strength > 50 ? 'yellow' : 'red'} + key={index} + size={4} + /> + )); + + return ( +
+ + + + {bars} + + + = 8} + /> + {checks} +
+ ); +} diff --git a/frontend/src/interfaces/club.tsx b/frontend/src/interfaces/club.tsx index fc60b1b2..bb32f53b 100644 --- a/frontend/src/interfaces/club.tsx +++ b/frontend/src/interfaces/club.tsx @@ -1,4 +1,4 @@ -export interface ClubInterface { +export interface Club { id: number; name: string; created: string; diff --git a/frontend/src/interfaces/user.tsx b/frontend/src/interfaces/user.tsx new file mode 100644 index 00000000..48e0e7dd --- /dev/null +++ b/frontend/src/interfaces/user.tsx @@ -0,0 +1,17 @@ +export interface UserInterface { + id: number; + created: string; + name: string; + email: string; +} + +export interface UserBodyInterface { + name: string; + email: string; +} + +export interface UserToRegisterInterface { + name: string; + email: string; + password: string; +} diff --git a/frontend/src/pages/404.tsx b/frontend/src/pages/404.tsx index 2ec8ba1f..5b422190 100644 --- a/frontend/src/pages/404.tsx +++ b/frontend/src/pages/404.tsx @@ -12,7 +12,7 @@ const useStyles = createStyles((theme) => ({ fontWeight: 900, fontSize: 220, lineHeight: 1, - marginBottom: theme.spacing.xl * 1.5, + marginBottom: theme.spacing.xl, color: theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2], [theme.fn.smallerThan('sm')]: { @@ -35,7 +35,7 @@ const useStyles = createStyles((theme) => ({ maxWidth: 500, margin: 'auto', marginTop: theme.spacing.xl, - marginBottom: theme.spacing.xl * 1.5, + marginBottom: theme.spacing.xl, }, })); diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 760eceb3..730efd22 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -1,5 +1,5 @@ import { ColorScheme, ColorSchemeProvider, MantineProvider } from '@mantine/core'; -import { NotificationsProvider } from '@mantine/notifications'; +import { Notifications } from '@mantine/notifications'; import { getCookie, setCookie } from 'cookies-next'; import NextApp, { AppContext, AppProps } from 'next/app'; import Head from 'next/head'; @@ -27,9 +27,8 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) { - - - + + diff --git a/frontend/src/pages/clubs.tsx b/frontend/src/pages/clubs.tsx new file mode 100644 index 00000000..9b4a4817 --- /dev/null +++ b/frontend/src/pages/clubs.tsx @@ -0,0 +1,25 @@ +import { Grid, Title } from '@mantine/core'; + +import ClubModal from '../components/modals/club_modal'; +import ClubsTable from '../components/tables/clubs'; +import { checkForAuthError, getClubs } from '../services/adapter'; +import Layout from './_layout'; + +export default function HomePage() { + const swrClubsResponse = getClubs(); + checkForAuthError(swrClubsResponse); + + return ( + + + + Clubs + + + + + + + + ); +} diff --git a/frontend/src/pages/create_account.tsx b/frontend/src/pages/create_account.tsx new file mode 100644 index 00000000..d0b84c30 --- /dev/null +++ b/frontend/src/pages/create_account.tsx @@ -0,0 +1,114 @@ +import { + Anchor, + Box, + Button, + Center, + Container, + Group, + Paper, + TextInput, + Title, + createStyles, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconArrowLeft } from '@tabler/icons'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import { PasswordStrength } from '../components/utils/password'; +import { registerUser } from '../services/user'; + +const useStyles = createStyles((theme) => ({ + title: { + fontSize: 26, + fontWeight: 900, + fontFamily: `Greycliff CF, ${theme.fontFamily}`, + }, + + controls: { + [theme.fn.smallerThan('xs')]: { + flexDirection: 'column-reverse', + }, + }, + + control: { + [theme.fn.smallerThan('xs')]: { + width: '100%', + textAlign: 'center', + }, + }, +})); + +export default function CreateAccount() { + const { classes } = useStyles(); + const router = useRouter(); + + async function registerAndRedirect(values: any) { + const response = await registerUser(values); + + if (response != null && response.data != null && response.data.data != null) { + localStorage.setItem('login', JSON.stringify(response.data.data)); + await router.push('/'); + } + } + + const form = useForm({ + initialValues: { + name: '', + email: '', + password: '', + }, + + validate: { + name: (value) => (value !== '' ? null : 'Name cannot be empty'), + email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'), + password: (value) => (value !== '' ? null : 'Password cannot be empty'), + }, + }); + + return ( + + + Create a new account + + +
{ + await registerAndRedirect(values); + })} + > + + + + + +
+ + router.push('/login')}> + {' '} + Back to login page + +
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 40334327..6eb403a7 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,7 +1,17 @@ -import { Button, Container, Paper, PasswordInput, Text, TextInput, Title } from '@mantine/core'; +import { + Anchor, + Button, + Container, + Paper, + PasswordInput, + Text, + TextInput, + Title, +} from '@mantine/core'; import { useForm } from '@mantine/form'; import { showNotification } from '@mantine/notifications'; import { useRouter } from 'next/router'; +import React from 'react'; import useStyles from '../components/login/login.styles'; import { performLogin } from '../services/user'; @@ -23,6 +33,7 @@ export default function Login() { await router.push('/'); } } + const form = useForm({ initialValues: { email: '', @@ -44,8 +55,29 @@ export default function Login() { Bracket - - + + + {/*}*/} + {/*>*/} + {/* Continue with GitHub*/} + {/**/} + {/*}*/} + {/*>*/} + {/* Continue with Google*/} + {/**/} + {/**/}
attemptLogin(values.email, values.password))} > @@ -53,6 +85,7 @@ export default function Login() { label="Email" placeholder="Your email" required + my="lg" type="email" {...form.getInputProps('email')} /> @@ -63,22 +96,20 @@ export default function Login() { mt="md" {...form.getInputProps('password')} /> - {/**/} - {/* onClick={(event) => event.preventDefault()} href="#" size="sm">*/} - {/* Forgot password?*/} - {/* */} - {/**/}
+ + onClick={() => router.push('/create_account')} size="sm"> + Create account + + {' - '} + onClick={() => router.push('/password_reset')} size="sm"> + Forgot password? + +
- {/**/} - {/* Do not have an account yet?{' '}*/} - {/* href="#" size="sm" onClick={(event) => event.preventDefault()}>*/} - {/* Create account*/} - {/* */} - {/**/}
); diff --git a/frontend/src/pages/password_reset.tsx b/frontend/src/pages/password_reset.tsx new file mode 100644 index 00000000..2526e2e3 --- /dev/null +++ b/frontend/src/pages/password_reset.tsx @@ -0,0 +1,73 @@ +import { + Anchor, + Box, + Button, + Center, + Container, + Group, + Paper, + Text, + TextInput, + Title, + createStyles, +} from '@mantine/core'; +import { IconArrowLeft } from '@tabler/icons'; +import { useRouter } from 'next/router'; + +import NotFoundTitle from './404'; + +const useStyles = createStyles((theme) => ({ + title: { + fontSize: 26, + fontWeight: 900, + fontFamily: `Greycliff CF, ${theme.fontFamily}`, + }, + + controls: { + [theme.fn.smallerThan('xs')]: { + flexDirection: 'column-reverse', + }, + }, + + control: { + [theme.fn.smallerThan('xs')]: { + width: '100%', + textAlign: 'center', + }, + }, +})); + +export default function ForgotPassword() { + // TODO: Implement this page. + return ; + + const { classes } = useStyles(); + const router = useRouter(); + + return ( + + + Forgot your password? + + + Enter your email to get a reset link + + + + + + +
+ + router.push('/login')}> + {' '} + Back to login page + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/user.tsx b/frontend/src/pages/user.tsx new file mode 100644 index 00000000..feb1c88b --- /dev/null +++ b/frontend/src/pages/user.tsx @@ -0,0 +1,27 @@ +import { Stack, Title } from '@mantine/core'; + +import UserForm from '../components/forms/user'; +import { checkForAuthError, getTournaments, getUser } from '../services/adapter'; +import { getLogin } from '../services/local_storage'; +import Layout from './_layout'; + +export default function HomePage() { + let user = null; + + const swrTournamentsResponse = getTournaments(); + checkForAuthError(swrTournamentsResponse); + + if (typeof window !== 'undefined') { + const swrUserResponse = getUser(getLogin().user_id); + user = swrUserResponse.data != null ? swrUserResponse.data.data : null; + } + + const form = user != null ? : null; + + return ( + + Edit profile + {form} + + ); +} diff --git a/frontend/src/services/adapter.tsx b/frontend/src/services/adapter.tsx index f4a43806..8deed757 100644 --- a/frontend/src/services/adapter.tsx +++ b/frontend/src/services/adapter.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import useSWR, { SWRResponse } from 'swr'; import { SchedulerSettings } from '../interfaces/match'; +import { getLogin } from './local_storage'; const axios = require('axios').default; @@ -34,8 +35,8 @@ export function getBaseApiUrl() { } export function createAxios() { - const user = localStorage.getItem('login'); - const access_token = user != null ? JSON.parse(user).access_token : ''; + const user = getLogin(); + const access_token = user != null ? user.access_token : ''; return axios.create({ baseURL: getBaseApiUrl(), headers: { @@ -76,6 +77,10 @@ export function getRounds(tournament_id: number, no_draft_rounds: boolean = fals }); } +export function getUser(user_id: number): SWRResponse { + return useSWR(`users/${user_id}`, fetcher); +} + export function getUpcomingMatches( tournament_id: number, schedulerSettings: SchedulerSettings diff --git a/frontend/src/services/club.tsx b/frontend/src/services/club.tsx new file mode 100644 index 00000000..e5b1dde7 --- /dev/null +++ b/frontend/src/services/club.tsx @@ -0,0 +1,21 @@ +import { createAxios, handleRequestError } from './adapter'; + +export async function createClub(name: string) { + return createAxios() + .post('clubs', { name }) + .catch((response: any) => handleRequestError(response)); +} + +export async function deleteClub(club_id: number) { + return createAxios() + .delete(`clubs/${club_id}`) + .catch((response: any) => handleRequestError(response)); +} + +export async function updateClub(club_id: number, name: string) { + return createAxios() + .patch(`clubs/${club_id}`, { + name, + }) + .catch((response: any) => handleRequestError(response)); +} diff --git a/frontend/src/services/local_storage.tsx b/frontend/src/services/local_storage.tsx new file mode 100644 index 00000000..25b3a27f --- /dev/null +++ b/frontend/src/services/local_storage.tsx @@ -0,0 +1,8 @@ +export function performLogout() { + localStorage.removeItem('login'); +} + +export function getLogin() { + const login = localStorage.getItem('login'); + return login != null ? JSON.parse(login) : {}; +} diff --git a/frontend/src/services/user.tsx b/frontend/src/services/user.tsx index 93bd49ec..0d8a7508 100644 --- a/frontend/src/services/user.tsx +++ b/frontend/src/services/user.tsx @@ -1,3 +1,4 @@ +import { UserBodyInterface, UserToRegisterInterface } from '../interfaces/user'; import { createAxios, handleRequestError } from './adapter'; export async function performLogin(username: string, password: string) { @@ -23,6 +24,20 @@ export async function performLogin(username: string, password: string) { return true; } -export function performLogout() { - localStorage.removeItem('login'); +export async function updateUser(user_id: number, user: UserBodyInterface) { + return createAxios() + .patch(`users/${user_id}`, user) + .catch((response: any) => handleRequestError(response)); +} + +export async function updatePassword(user_id: number, password: string) { + return createAxios() + .patch(`users/${user_id}/password`, { password }) + .catch((response: any) => handleRequestError(response)); +} + +export async function registerUser(user: UserToRegisterInterface) { + return createAxios() + .post('users/register', user) + .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c9a3ca0e..22b51752 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -530,32 +530,33 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@floating-ui/core@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.1.tgz#00e64d74e911602c8533957af0cce5af6b2e93c8" - integrity sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA== +"@floating-ui/core@^1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0" + integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg== -"@floating-ui/dom@^1.0.0": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.2.tgz#c5184c52c6f50abd11052d71204f4be2d9245237" - integrity sha512-5X9WSvZ8/fjy3gDu8yx9HAA4KG1lazUN2P4/VnaXLxTO9Dz53HI1oYoh1OlhqFNlHgGDiwFX5WhFCc2ljbW3yA== +"@floating-ui/dom@^1.2.1": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.6.tgz#bcf0c7bada97c20d9d1255b889f35bac838c63fe" + integrity sha512-02vxFDuvuVPs22iJICacezYJyf7zwwOCWkPNkWNBr1U0Qt1cKFYzWvxts0AmqcOQGwt/3KJWcWIgtbUU38keyw== dependencies: - "@floating-ui/core" "^1.0.1" + "@floating-ui/core" "^1.2.6" -"@floating-ui/react-dom-interactions@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.10.1.tgz#45fc7c3d9a2ae58f0ef39078660e97594f484af8" - integrity sha512-mb9Sn/cnPjVlEucSZTSt4Iu7NAvqnXTvmzeE5EtfdRhVQO6L94dqqT+DPTmJmbiw4XqzoyGP+Q6J+I5iK2p6bw== +"@floating-ui/react-dom@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.3.0.tgz#4d35d416eb19811c2b0e9271100a6aa18c1579b3" + integrity sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g== dependencies: - "@floating-ui/react-dom" "^1.0.0" + "@floating-ui/dom" "^1.2.1" + +"@floating-ui/react@^0.19.1": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.2.tgz#c6e4d2097ed0dca665a7c042ddf9cdecc95e9412" + integrity sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w== + dependencies: + "@floating-ui/react-dom" "^1.3.0" aria-hidden "^1.1.3" - -"@floating-ui/react-dom@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.0.0.tgz#e0975966694433f1f0abffeee5d8e6bb69b7d16e" - integrity sha512-uiOalFKPG937UCLm42RxjESTWUVpbbatvlphQAU6bsv+ence6IoVG8JOUZcy8eW81NkU+Idiwvx10WFLmR4MIg== - dependencies: - "@floating-ui/dom" "^1.0.0" + tabbable "^6.0.1" "@humanwhocodes/config-array@^0.9.2": version "0.9.5" @@ -814,87 +815,75 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@mantine/core@^5.9.5": - version "5.9.5" - resolved "https://registry.yarnpkg.com/@mantine/core/-/core-5.9.5.tgz#4dda2c1297d74ff319350956bae0332b317ebde3" - integrity sha512-A3cYzGOJ9BpU6tgqTl8qzOe8mmqzvuB76N6IHsPjk+uhbQCBXuNaoxOemP0wEM4HpEAzH1FR1kGhk6tO3gogUA== +"@mantine/core@^6.0.5": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/core/-/core-6.0.6.tgz#e09086c798ee546f02e2da07ad617d8249791029" + integrity sha512-0DtwlNcHEb6d8/qtoYCFHc/at+8w1sg3TUWh+cdFFiykZqpRrXBRoGGfbElN5RHtQqAVg4BXC0jDSHpz6CjPHw== dependencies: - "@floating-ui/react-dom-interactions" "^0.10.1" - "@mantine/styles" "5.9.5" - "@mantine/utils" "5.9.5" + "@floating-ui/react" "^0.19.1" + "@mantine/styles" "6.0.6" + "@mantine/utils" "6.0.6" "@radix-ui/react-scroll-area" "1.0.2" + react-remove-scroll "^2.5.5" react-textarea-autosize "8.3.4" -"@mantine/dropzone@^5.9.5": - version "5.10.0" - resolved "https://registry.yarnpkg.com/@mantine/dropzone/-/dropzone-5.10.0.tgz#bf022d5f24e90e29812e4f3a6304787b089980d5" - integrity sha512-SpDi9FEFCtNZnxfGojvrUg3zwoe4Ueyip0dIJ6D70/q6M7WMStuLhDVVLUgrhAr0REArUa3HWPx9AfdZ/TAOEg== +"@mantine/dropzone@^6.0.5": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/dropzone/-/dropzone-6.0.6.tgz#06718f7cf03dfab335803633f9bf772551df8841" + integrity sha512-0vXJmVkrihcT+6H43DvZ+djBkJOF3hzrITq+MzPyyPQ+r+hfbAYlmnGmLzAAw/+0MdRSM2NA4+2f6G+kGgfnTw== dependencies: - "@mantine/utils" "5.10.0" + "@mantine/utils" "6.0.6" react-dropzone "14.2.3" -"@mantine/form@^5.10.0": - version "5.10.0" - resolved "https://registry.yarnpkg.com/@mantine/form/-/form-5.10.0.tgz#d01b6b593d744466e71d9b2d72dc85569c3e5eca" - integrity sha512-4X9RR75Aaq0fxu+kJ/mL/CAOPlgHNhbUY657hGJlfwqWJFwibnhyCe1s5lpZ7Sm8F3hNsSMpWzdA687gvhw4Lw== +"@mantine/form@^6.0.5": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/form/-/form-6.0.6.tgz#82e4e71ae6d4810eded8faecea9531bb24e210bf" + integrity sha512-ZTudX5bW5i0dlNWqngd6B0HcV2hWNFf+IpRO4Pz+MYYUrp90lLftgIdr6cISEOsjM7v3tCKFNCjGhl7rck9p0w== dependencies: fast-deep-equal "^3.1.3" klona "^2.0.5" -"@mantine/hooks@^5.9.5": - version "5.9.5" - resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-5.9.5.tgz#924fc01242e89a6630ae4d5f7c2208ddf7796160" - integrity sha512-6u0oj5zFYAP8bY+iW5Y5HEFS6tZmvJN5KwNPH+F2Omw61hN7shehHED7Jbe5zkxcggFvqmkA/6FMk+VYfovmkA== +"@mantine/hooks@^6.0.5": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.6.tgz#d571eaaa13f5f191740381c101aab3ac45c703fb" + integrity sha512-yGqGIZVgvL33XL+4UbP/BcvH/R5SKJYfrNcKIQY+SZ4y3CP05Q/p0BpjcLUJowKbD+xHaqXnMxE8yGf9KWAZig== -"@mantine/next@^5.10.5": - version "5.10.5" - resolved "https://registry.yarnpkg.com/@mantine/next/-/next-5.10.5.tgz#fd504b86ccc242ab60880b34ec3bfb6157be6bfc" - integrity sha512-sFQ4RbRPHzrBfEkSyg+6mb08MqFY4nrNkJh/PCe71U67GGy/djidSgW5++glPxISKQk/Zftay0s6Gm4IlYMO4g== +"@mantine/next@^6.0.5": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/next/-/next-6.0.6.tgz#46b284cfc77764bcf5d6fa5d81707b92dc84982b" + integrity sha512-/ZUUPdgmWW5wxn5/lEuDqYzgBE7EJhP2y/cT9QoI2DyEf/drLc20Ccg4dHKv4d9UqH6jWWDXC5mSKArq9oTPhw== dependencies: - "@mantine/ssr" "5.10.5" - "@mantine/styles" "5.10.5" + "@mantine/ssr" "6.0.6" + "@mantine/styles" "6.0.6" -"@mantine/notifications@^5.9.5": - version "5.9.5" - resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-5.9.5.tgz#508e7f4e7de1603e17d0aa03e6abaa1b7ff456e3" - integrity sha512-RGr48xPVMjlt0l6LEG7sncgTf4oKHxBDtLYOBwybNuLs1pcpmoHGdcgCkvINnXKWFHF8IQG/l3eDlKBseromJQ== +"@mantine/notifications@^6.0.5": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-6.0.6.tgz#b4b64f7a951920e3c134dbc82c7eab5f49a31afd" + integrity sha512-QoUG8CB6w88OGt8EGWjqpopO3fse90kSOB6GQ5ljiB4zupifhtvDVPT+Hq2GxATqXvLEipjsMJLJK15jNJOuZw== dependencies: - "@mantine/utils" "5.9.5" + "@mantine/utils" "6.0.6" react-transition-group "4.4.2" -"@mantine/ssr@5.10.5": - version "5.10.5" - resolved "https://registry.yarnpkg.com/@mantine/ssr/-/ssr-5.10.5.tgz#781d0ba44b13c4430746ee3574381673b4424377" - integrity sha512-sp2ZnDHEaxsF0YNwrqBYTTGX7wL2DaHGOSVeZxnGX1lcOBkv5ksshdGnOU2cM2M8kKZumaDaAxbNiPKOnLsy3A== +"@mantine/ssr@6.0.6": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/ssr/-/ssr-6.0.6.tgz#7283bd475e96a2a830a64ed1c79948d65148e723" + integrity sha512-0XSkO6lKwf36LYB3vCIcaCALei4NsfZ3raK2wmpS7IOV5zF+c5EjoVxIxwd4p93GHR3D/0CW9whQrPHlcoOaOA== dependencies: - "@mantine/styles" "5.10.5" + "@mantine/styles" "6.0.6" html-react-parser "1.4.12" -"@mantine/styles@5.10.5": - version "5.10.5" - resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-5.10.5.tgz#ace82a71b4fe3d14ee14638f1735d5680d93d36d" - integrity sha512-0NXk8c/XGzuTUkZc6KceF2NaTCMEu5mHR4ru0x+ttb9DGnLpHuGWduTHjSfr4hl6eAJgedD0zauO+VAhDzO9zA== +"@mantine/styles@6.0.6": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.6.tgz#a71c9289bbe7cc863823e3446c76996cc6a5940f" + integrity sha512-jNC4uTANTl3Z4B3H+oohJd9i7A542asn6UPUR+xMyjNk2guWSQcLUXe2x3VpL6G71ybNF7UQuZTEyar6OaKW3w== dependencies: clsx "1.1.1" csstype "3.0.9" -"@mantine/styles@5.9.5": - version "5.9.5" - resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-5.9.5.tgz#921a443e5101c2e9887b2559e68c487f0e7a9a7c" - integrity sha512-Rixu60eVS9aP8ugTM0Yoc5MpXXsfemRlu2PDZGL0fhcOyUYHi5mXvlhgxqrET3zkFrBJ+PHuPLQBgBimffGqiw== - dependencies: - clsx "1.1.1" - csstype "3.0.9" - -"@mantine/utils@5.10.0": - version "5.10.0" - resolved "https://registry.yarnpkg.com/@mantine/utils/-/utils-5.10.0.tgz#d77bdbd4fbf0ef7b10dd0c6480ce9a035dd31ae9" - integrity sha512-mHnNm0ajIa8qLAIEwv82N6+7YKecynOA3I8vzgBHXS2x4HwGsHITFYGmMh2LNpx5dRL034tObfEFYZXqncyEDw== - -"@mantine/utils@5.9.5": - version "5.9.5" - resolved "https://registry.yarnpkg.com/@mantine/utils/-/utils-5.9.5.tgz#9b56c899e479c90abecc096eb8543b93bdc52938" - integrity sha512-OtMOvXMyqpZ+Tz25DYRwRkvERvmF4L0RJiq+JnXk+1yKDvG+JZQuMMLnt0nZ81T6q7uzDSA29cJ45syHL2BXmQ== +"@mantine/utils@6.0.6": + version "6.0.6" + resolved "https://registry.yarnpkg.com/@mantine/utils/-/utils-6.0.6.tgz#19bff743cf69c0c0b39664a633cb7827bc996833" + integrity sha512-Pd+ZTJ3maXb1zihZzHpo00XLwcKEg+ZGLPFG5vg/YafLDDWIhgV6snTFVKsPb2cunVjOwzbKcvlmCHXBe97mTQ== "@next/bundle-analyzer@^13.2.1": version "13.2.1" @@ -2138,6 +2127,11 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" @@ -2856,6 +2850,11 @@ get-intrinsic@^1.1.3: has "^1.0.3" has-symbols "^1.0.3" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" @@ -3165,6 +3164,13 @@ internal-slot@^1.0.4: has "^1.0.3" side-channel "^1.0.4" +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -4000,7 +4006,7 @@ lodash@4.17.21, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.7.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4535,6 +4541,34 @@ react-redux@^8.0.5: react-is "^18.0.0" use-sync-external-store "^1.0.0" +react-remove-scroll-bar@^2.3.3: + version "2.3.4" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9" + integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A== + dependencies: + react-style-singleton "^2.2.1" + tslib "^2.0.0" + +react-remove-scroll@^2.5.5: + version "2.5.5" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77" + integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw== + dependencies: + react-remove-scroll-bar "^2.3.3" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.0" + use-sidecar "^1.1.2" + +react-style-singleton@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" + integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g== + dependencies: + get-nonce "^1.0.0" + invariant "^2.2.4" + tslib "^2.0.0" + react-textarea-autosize@8.3.4: version "8.3.4" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524" @@ -5010,6 +5044,11 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tabbable@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.1.1.tgz#40cfead5ed11be49043f04436ef924c8890186a0" + integrity sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg== + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" @@ -5117,6 +5156,11 @@ tslib@^1.0.0, tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0, tslib@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" + integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== + tslib@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" @@ -5210,6 +5254,13 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-callback-ref@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5" + integrity sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w== + dependencies: + tslib "^2.0.0" + use-composed-ref@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" @@ -5227,6 +5278,14 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-sidecar@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2" + integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"