Improve auth and user management (#125)

This commit is contained in:
Erik Vroon
2023-04-16 13:15:41 +02:00
committed by GitHub
parent 7569248a4c
commit 7c487952da
65 changed files with 1603 additions and 429 deletions

View File

@@ -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"

View File

@@ -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"}

View File

@@ -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 ###

View File

@@ -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'])

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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
),
)
)
)

View File

@@ -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 = '''

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)
)
)

View File

@@ -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]

View File

@@ -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(

View File

View File

@@ -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

View File

@@ -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]

View File

@@ -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}'

View File

@@ -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]

View File

@@ -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

View File

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

1
backend/cli.py Normal file → Executable file
View File

@@ -1,3 +1,4 @@
#!/usr/bin/env python3
import asyncio
import functools
from typing import Any

View File

@@ -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 <socket.socket.*:ResourceWarning',
'ignore:.*unclosed event loop <_UnixSelectorEventLoop.*:ResourceWarning',
'ignore:pkg_resources is deprecated as an API.*:DeprecationWarning',
'ignore:Deprecated call to `pkg_resources.declare_namespace(.*)`.*:DeprecationWarning',
]
[tool.mypy]
@@ -47,12 +45,22 @@ filterwarnings = [
no_implicit_reexport = true
show_error_codes = true
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
# Per-module options:
[[tool.mypy.overrides]]
module = ['aioresponses.*']
disallow_any_explicit = false
disallow_any_generics = false
[[tool.mypy.overrides]]
module = ['fastapi_sso.*']
ignore_missing_imports = true
[tool.pylint.'MESSAGES CONTROL']
disable = [
'missing-docstring',
@@ -64,6 +72,7 @@ disable = [
'duplicate-code',
'too-many-locals',
'too-many-nested-blocks',
'protected-access',
]
[tool.bandit]

View File

@@ -52,13 +52,17 @@ async def test_auth_on_protected_endpoint(startup_and_shutdown_uvicorn_server: N
headers = {'Authorization': f'Bearer {get_mock_token()}'}
async with inserted_user(MOCK_USER) as user_inserted:
response = JsonDict(await send_request(HTTPMethod.GET, 'users/me', {}, None, headers))
response = JsonDict(
await send_request(HTTPMethod.GET, f'users/{user_inserted.id}', {}, None, headers)
)
assert response == {
'id': user_inserted.id,
'email': user_inserted.email,
'name': user_inserted.name,
'created': '2200-01-01T00:00:00+00:00',
'data': {
'id': user_inserted.id,
'email': user_inserted.email,
'name': user_inserted.name,
'created': '2200-01-01T00:00:00+00:00',
}
}

View File

@@ -1,5 +1,7 @@
from bracket.sql.clubs import get_clubs_for_user_id, sql_delete_club
from bracket.utils.dummy_records import DUMMY_MOCK_TIME
from bracket.utils.http import HTTPMethod
from bracket.utils.types import assert_some
from tests.integration_tests.api.shared import send_auth_request
from tests.integration_tests.models import AuthContext
@@ -16,3 +18,20 @@ async def test_clubs_endpoint(
}
],
}
async def test_create_club(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> 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]

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,

View File

@@ -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",

View File

@@ -2,21 +2,21 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
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"
xmlns:svg="http://www.w3.org/2000/svg">
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"
>
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -99,6 +99,18 @@ export default function Match({
<Grid.Col span={2}>{match.team2_score}</Grid.Col>
</Grid>
</div>
</>
);
if (readOnly) {
return <div className={classes.root}>{bracket}</div>;
}
return (
<>
<UnstyledButton className={classes.root} onClick={() => setOpened(!opened)}>
{bracket}
</UnstyledButton>
<MatchModal
swrRoundsResponse={swrRoundsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
@@ -109,14 +121,4 @@ export default function Match({
/>
</>
);
if (readOnly) {
return <div className={classes.root}>{bracket}</div>;
}
return (
<UnstyledButton className={classes.root} onClick={() => setOpened(!opened)}>
{bracket}
</UnstyledButton>
);
}

View File

@@ -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 (
<Tabs defaultValue="details">
<Tabs.List>
<Tabs.Tab value="details" icon={<IconUser size="1.0rem" />}>
Edit details
</Tabs.Tab>
<Tabs.Tab value="password" icon={<IconHash size="1.0rem" />}>
Edit password
</Tabs.Tab>
{/*<Tabs.Tab value="settings" icon={<IconSettings size="1.0rem" />}>*/}
{/* Settings*/}
{/*</Tabs.Tab>*/}
</Tabs.List>
<Tabs.Panel value="details" pt="xs">
<form
onSubmit={details_form.onSubmit(async (values) => {
if (user != null) await updateUser(user.id, values);
})}
>
<TextInput
withAsterisk
mt="1.0rem"
label="Name"
{...details_form.getInputProps('name')}
/>
<TextInput
withAsterisk
mt="1.0rem"
label="Email"
type="email"
{...details_form.getInputProps('email')}
/>
<Button fullWidth style={{ marginTop: 20 }} color="green" type="submit">
Save
</Button>
</form>
</Tabs.Panel>
<Tabs.Panel value="password" pt="xs">
<form
onSubmit={password_form.onSubmit(async (values) => {
if (user != null) await updatePassword(user.id, values.password);
})}
>
<PasswordStrength form={password_form} />
<Button fullWidth style={{ marginTop: 20 }} color="green" type="submit">
Save
</Button>
</form>
</Tabs.Panel>
</Tabs>
);
}

View File

@@ -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 ? <GoPlus size={20} /> : <BiEditAlt size={20} />;
const [opened, setOpened] = useState(false);
const modalOpenButton = is_create_form ? (
<Group position="right">
<SaveButton
onClick={() => setOpened(true)}
leftIcon={<GoPlus size={24} />}
title={operation_text}
/>
</Group>
) : (
<Button
color="green"
size="xs"
style={{ marginRight: 10 }}
onClick={() => setOpened(true)}
leftIcon={icon}
>
{operation_text}
</Button>
);
const form = useForm({
initialValues: {
name: club == null ? '' : club.name,
},
validate: {
name: (value) => (value.length > 0 ? null : 'Name too short'),
},
});
return (
<>
<Modal opened={opened} onClose={() => setOpened(false)} title={operation_text}>
<form
onSubmit={form.onSubmit(async (values) => {
if (is_create_form) await createClub(values.name);
else await updateClub(club.id, values.name);
await swrClubsResponse.mutate(null);
setOpened(false);
})}
>
<TextInput
withAsterisk
label="Name"
placeholder="Best Club Ever"
{...form.getInputProps('name')}
/>
<Button fullWidth style={{ marginTop: 10 }} color="green" type="submit">
Save
</Button>
</form>
</Modal>
{modalOpenButton}
</>
);
}

View File

@@ -67,6 +67,7 @@ export default function MatchModal({
placeholder={`Score of ${match.team2.name}`}
{...form.getInputProps('team2_score')}
/>
<TextInput
withAsterisk
style={{ marginTop: 20 }}
@@ -85,7 +86,7 @@ export default function MatchModal({
await swrRoundsResponse.mutate(null);
if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null);
}}
style={{ marginTop: '15px' }}
style={{ marginTop: '1rem' }}
size="sm"
title="Remove Match"
/>

View File

@@ -98,6 +98,7 @@ export default function TeamModal({
placeholder="Pick all that you like"
searchable
limit={20}
mt={12}
{...form.getInputProps('player_ids')}
/>

View File

@@ -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({
</Button>
);
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

View File

@@ -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 (
<Navbar.Section className={classes.footer}>
<a href={`${getBaseApiUrl()}/docs`} className={classes.link}>
<FaBook className={classes.linkIcon} size={20} />
<span>API documentation</span>
</a>
<>
<Navbar.Section className={classes.footer}>
<a href={`${getBaseApiUrl()}/docs`} className={classes.link}>
<FaBook className={classes.linkIcon} size={20} />
<span>API documentation</span>
</a>
<a href="https://github.com/evroon/bracket" className={classes.link}>
<FaGithub className={classes.linkIcon} size={20} />
<span>Bracket on GitHub</span>
</a>
<a href="https://github.com/evroon/bracket" className={classes.link}>
<FaGithub className={classes.linkIcon} size={20} />
<span>Bracket on GitHub</span>
</a>
</Navbar.Section>
<a href="#" className={classes.link} onClick={() => attemptLogout()}>
<IconLogout className={classes.linkIcon} stroke={1.5} />
<span>Logout</span>
</a>
</Navbar.Section>
<Navbar.Section className={classes.footer}>
<a href="#" className={classes.link} onClick={() => router.push('/user')}>
<IconUser className={classes.linkIcon} stroke={1.5} />
<span>User</span>
</a>
<a href="#" className={classes.link} onClick={() => router.push('/clubs')}>
<IconCategory2 className={classes.linkIcon} stroke={1.5} />
<span>Clubs</span>
</a>
<a href="#" className={classes.link} onClick={() => attemptLogout()}>
<IconLogout className={classes.linkIcon} stroke={1.5} />
<span>Logout</span>
</a>
</Navbar.Section>
</>
);
}

View File

@@ -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"
>
<Radio value="true" label="Only players who played less" />
<Radio value="false" label="All matches" />
<Group mt={8}>
<Radio value="true" label="Only players who played less" />
<Radio value="false" label="All matches" />
</Group>
</Radio.Group>
<Divider orientation="vertical" />
<NumberInput

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { SWRResponse } from 'swr';
import { Club } from '../../interfaces/club';
import { deleteClub } from '../../services/club';
import DeleteButton from '../buttons/delete';
import ClubModal from '../modals/club_modal';
import RequestErrorAlert from '../utils/error_alert';
import TableLayout, { ThNotSortable, ThSortable, getTableState, sortTableEntries } from './table';
export default function ClubsTable({ swrClubsResponse }: { swrClubsResponse: SWRResponse }) {
const clubs: Club[] = swrClubsResponse.data != null ? swrClubsResponse.data.data : [];
const tableState = getTableState('name');
if (swrClubsResponse.error) return <RequestErrorAlert error={swrClubsResponse.error} />;
const rows = clubs
.sort((p1: Club, p2: Club) => sortTableEntries(p1, p2, tableState))
.map((club) => (
<tr key={club.name}>
<td>{club.name}</td>
<td>
<ClubModal swrClubsResponse={swrClubsResponse} club={club} />
<DeleteButton
onClick={async () => {
await deleteClub(club.id);
await swrClubsResponse.mutate(null);
}}
title="Delete Club"
/>
</td>
</tr>
));
return (
<TableLayout>
<thead>
<tr>
<ThSortable state={tableState} field="name">
Title
</ThSortable>
<ThNotSortable>{null}</ThNotSortable>
</tr>
</thead>
<tbody>{rows}</tbody>
</TableLayout>
);
}

View File

@@ -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 (
<Text color={meets ? 'teal' : 'red'} mt={5} size="sm">
<Center inline>
{meets ? <IconCheck size={14} stroke={1.5} /> : <IconX size={14} stroke={1.5} />}
<Box ml={7}>{label}</Box>
</Center>
</Text>
);
}
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) => (
<PasswordRequirement
key={index}
label={requirement.label}
meets={requirement.re.test(form.values.password)}
/>
));
const bars = Array(4)
.fill(0)
.map((_, index) => (
<Progress
styles={{ bar: { transitionDuration: '0ms' } }}
value={
form.values.password.length > 0 && index === 0
? 100
: strength >= ((index + 1) / 4) * 100
? 100
: 0
}
color={strength > 80 ? 'teal' : strength > 50 ? 'yellow' : 'red'}
key={index}
size={4}
/>
));
return (
<div style={{ marginBottom: '1.0rem' }}>
<PasswordInput
value={form.values.password}
placeholder="Your password"
label="Password"
mt="1.0rem"
{...form.getInputProps('password')}
/>
<Group spacing={5} grow mt="xs" mb="md">
{bars}
</Group>
<PasswordRequirement
label="Has at least 8 characters"
meets={form.values.password.length >= 8}
/>
{checks}
</div>
);
}

View File

@@ -1,4 +1,4 @@
export interface ClubInterface {
export interface Club {
id: number;
name: string;
created: string;

View File

@@ -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;
}

View File

@@ -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,
},
}));

View File

@@ -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 }) {
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<MantineProvider theme={{ colorScheme }} withGlobalStyles withNormalizeCSS>
<NotificationsProvider>
<Component {...pageProps} />
</NotificationsProvider>
<Notifications />
<Component {...pageProps} />
</MantineProvider>
</ColorSchemeProvider>
</>

View File

@@ -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 (
<Layout>
<Grid grow>
<Grid.Col span={9}>
<Title>Clubs</Title>
</Grid.Col>
<Grid.Col span={3}>
<ClubModal swrClubsResponse={swrClubsResponse} club={null} />
</Grid.Col>
</Grid>
<ClubsTable swrClubsResponse={swrClubsResponse} />
</Layout>
);
}

View File

@@ -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 (
<Container size={460} my={30}>
<Title className={classes.title} align="center">
Create a new account
</Title>
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<form
onSubmit={form.onSubmit(async (values) => {
await registerAndRedirect(values);
})}
>
<TextInput
label="Email Address"
placeholder="Email Address"
required
type="email"
{...form.getInputProps('email')}
/>
<TextInput
label="Name"
placeholder="Name"
required
mt="lg"
mb="lg"
{...form.getInputProps('name')}
/>
<PasswordStrength form={form} />
<Group position="apart" mt="lg" className={classes.controls}>
<Anchor color="dimmed" size="sm" className={classes.control}>
<Center inline>
<IconArrowLeft size={12} stroke={1.5} />
<Box ml={5} onClick={() => router.push('/login')}>
{' '}
Back to login page
</Box>
</Center>
</Anchor>
<Button className={classes.control} type="submit">
Create account
</Button>
</Group>
</form>
</Paper>
</Container>
);
}

View File

@@ -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
</Text>
</Title>
<Container size={420} my={40}>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<Container size={480} my={40}>
<Paper withBorder shadow="md" p={30} pt={8} mt={30} radius="md">
{/*<Button*/}
{/* size="md"*/}
{/* fullWidth*/}
{/* mt="lg"*/}
{/* type="submit"*/}
{/* color="gray"*/}
{/* leftIcon={<FaGithub size={20} />}*/}
{/*>*/}
{/* Continue with GitHub*/}
{/*</Button>*/}
{/*<Button*/}
{/* size="md"*/}
{/* fullWidth*/}
{/* mt="lg"*/}
{/* type="submit"*/}
{/* color="indigo"*/}
{/* leftIcon={<FaGoogle size={20} />}*/}
{/*>*/}
{/* Continue with Google*/}
{/*</Button>*/}
{/*<Divider label="Or continue with email" labelPosition="center" my="lg" />*/}
<form
onSubmit={form.onSubmit(async (values) => 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')}
/>
{/*<Group position="right" mt="lg">*/}
{/* <Anchor<'a'> onClick={(event) => event.preventDefault()} href="#" size="sm">*/}
{/* Forgot password?*/}
{/* </Anchor>*/}
{/*</Group>*/}
<Button fullWidth mt="xl" type="submit">
Sign in
</Button>
</form>
<Text color="dimmed" size="sm" align="center" mt={15}>
<Anchor<'a'> onClick={() => router.push('/create_account')} size="sm">
Create account
</Anchor>
{' - '}
<Anchor<'a'> onClick={() => router.push('/password_reset')} size="sm">
Forgot password?
</Anchor>
</Text>
</Paper>
{/*<Text color="dimmed" size="sm" align="center" mt={15}>*/}
{/* Do not have an account yet?{' '}*/}
{/* <Anchor<'a'> href="#" size="sm" onClick={(event) => event.preventDefault()}>*/}
{/* Create account*/}
{/* </Anchor>*/}
{/*</Text>*/}
</Container>
</Layout>
);

View File

@@ -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 <NotFoundTitle />;
const { classes } = useStyles();
const router = useRouter();
return (
<Container size={460} my={30}>
<Title className={classes.title} align="center">
Forgot your password?
</Title>
<Text color="dimmed" size="sm" align="center">
Enter your email to get a reset link
</Text>
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<TextInput label="Email Address" placeholder="Email Address" required />
<Group position="apart" mt="lg" className={classes.controls}>
<Anchor color="dimmed" size="sm" className={classes.control}>
<Center inline>
<IconArrowLeft size={12} stroke={1.5} />
<Box ml={5} onClick={() => router.push('/login')}>
{' '}
Back to login page
</Box>
</Center>
</Anchor>
<Button className={classes.control}>Reset password</Button>
</Group>
</Paper>
</Container>
);
}

View File

@@ -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 ? <UserForm user={user} /> : null;
return (
<Layout>
<Title>Edit profile</Title>
<Stack style={{ width: '400px' }}>{form}</Stack>
</Layout>
);
}

View File

@@ -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

View File

@@ -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));
}

View File

@@ -0,0 +1,8 @@
export function performLogout() {
localStorage.removeItem('login');
}
export function getLogin() {
const login = localStorage.getItem('login');
return login != null ? JSON.parse(login) : {};
}

View File

@@ -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));
}

View File

@@ -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"