mirror of
https://github.com/evroon/bracket.git
synced 2026-04-22 16:27:05 -04:00
Improve auth and user management (#125)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
0
backend/bracket/routes/__init__.py
Normal file
0
backend/bracket/routes/__init__.py
Normal 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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 = '''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
85
backend/bracket/routes/users.py
Normal file
85
backend/bracket/routes/users.py
Normal 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)
|
||||
)
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
0
backend/bracket/sql/__init__.py
Normal file
0
backend/bracket/sql/__init__.py
Normal file
77
backend/bracket/sql/clubs.py
Normal file
77
backend/bracket/sql/clubs.py
Normal 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
|
||||
23
backend/bracket/sql/players.py
Normal file
23
backend/bracket/sql/players.py
Normal 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]
|
||||
54
backend/bracket/sql/rounds.py
Normal file
54
backend/bracket/sql/rounds.py
Normal 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}'
|
||||
36
backend/bracket/sql/teams.py
Normal file
36
backend/bracket/sql/teams.py
Normal 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]
|
||||
78
backend/bracket/sql/users.py
Normal file
78
backend/bracket/sql/users.py
Normal 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
|
||||
0
backend/bracket/utils/__init__.py
Normal file
0
backend/bracket/utils/__init__.py
Normal 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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
1
backend/cli.py
Normal file → Executable file
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
84
frontend/src/components/forms/user.tsx
Normal file
84
frontend/src/components/forms/user.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/modals/club_modal.tsx
Normal file
80
frontend/src/components/modals/club_modal.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function TeamModal({
|
||||
placeholder="Pick all that you like"
|
||||
searchable
|
||||
limit={20}
|
||||
mt={12}
|
||||
{...form.getInputProps('player_ids')}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
48
frontend/src/components/tables/clubs.tsx
Normal file
48
frontend/src/components/tables/clubs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/utils/password.tsx
Normal file
82
frontend/src/components/utils/password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface ClubInterface {
|
||||
export interface Club {
|
||||
id: number;
|
||||
name: string;
|
||||
created: string;
|
||||
|
||||
17
frontend/src/interfaces/user.tsx
Normal file
17
frontend/src/interfaces/user.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
25
frontend/src/pages/clubs.tsx
Normal file
25
frontend/src/pages/clubs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/pages/create_account.tsx
Normal file
114
frontend/src/pages/create_account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
73
frontend/src/pages/password_reset.tsx
Normal file
73
frontend/src/pages/password_reset.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/pages/user.tsx
Normal file
27
frontend/src/pages/user.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
21
frontend/src/services/club.tsx
Normal file
21
frontend/src/services/club.tsx
Normal 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));
|
||||
}
|
||||
8
frontend/src/services/local_storage.tsx
Normal file
8
frontend/src/services/local_storage.tsx
Normal 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) : {};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user