24 Commits

Author SHA1 Message Date
cb2b7db7a6 feat: demo MVP list 2025-05-08 10:11:00 +02:00
1c71df781c feat: adjustments for Demo Team (team_id = 42) 2025-05-08 07:07:14 +02:00
6378488fd0 feat: add demo players 2025-05-08 07:05:44 +02:00
6902ffdca6 fix: weigh dislike negatively for weighted degree 2025-04-16 12:59:50 +02:00
a6d0f528d0 fix: left out saving the password hash 2025-03-27 16:17:19 +01:00
77d292974c fix: group token cannot be one-time only 2025-03-27 08:04:41 +01:00
43f9b0d47c feat: register new user with team-specific token
revamp entire `SetPassword` page
2025-03-26 17:37:02 +01:00
bef5119a0b feat: more helpful error 2025-03-26 17:36:52 +01:00
ee13d06ab1 feat: no user -> no team 2025-03-26 16:31:27 +01:00
03ed843679 feat: check whether user has necessary scope 2025-03-25 19:09:33 +01:00
81d6a02229 feat: disallow non-members to list team members 2025-03-25 16:38:44 +01:00
11f3f9f440 fix: weird bug
cutt.0124816.xyz/team responded with 404 while all other routes were handled correctly
by react-router
2025-03-25 16:31:25 +01:00
0507b9f7c4 feat: default export TeamPanel 2025-03-25 09:13:26 +01:00
e701ebbb02 feat: remove unused / 2025-03-25 09:12:55 +01:00
d3daa83d68 fix: /team -> /teams 2025-03-25 07:45:34 +01:00
90adb4fc9c fix: auto enable new players 2025-03-24 22:41:03 +01:00
19ae4a18ca fix: show players if they're not disabled 2025-03-24 22:37:59 +01:00
fc8592f8ab fix: bar height 2025-03-24 14:19:05 +01:00
195d240a87 feat: use better query 2025-03-24 14:18:40 +01:00
df16497476 feat: add Team nav link 2025-03-24 14:14:11 +01:00
8b4ee3b289 feat: Team management panel
the display name of a player is the same for all teams... change that?
2025-03-24 14:11:58 +01:00
e88eb02ef1 fix: allow for nodes without any edges (e.g. new player) 2025-03-24 13:35:36 +01:00
c04a1e03f2 feat: improve TeamPanel input placement 2025-03-24 11:57:51 +01:00
691b99daa8 feat: add a Team Panel 2025-03-23 15:01:26 +01:00
19 changed files with 1035 additions and 229 deletions

View File

@@ -1,4 +1,6 @@
import io import io
import itertools
import random
import base64 import base64
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, HTTPException, Security, status from fastapi import APIRouter, HTTPException, Security, status
@@ -6,12 +8,13 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlmodel import Session, func, select from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar from sqlmodel.sql.expression import SelectOfScalar
from cutt.db import Chemistry, MVPRanking, Player, Team, engine from cutt.db import Chemistry, MVPRanking, Player, PlayerTeamLink, Team, engine
import networkx as nx import networkx as nx
import numpy as np import numpy as np
import matplotlib import matplotlib
from cutt.security import TeamScopedRequest, verify_team_scope from cutt.security import TeamScopedRequest, verify_team_scope
from cutt.demo import demo_players
matplotlib.use("agg") matplotlib.use("agg")
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
@@ -55,18 +58,77 @@ def sociogram_json():
def graph_json( def graph_json(
request: Annotated[ request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"]) networkx_graph: bool = False,
],
): ):
nodes = [] nodes = []
edges = [] edges = []
player_map = {} player_map = {}
if request.team_id == 42:
players = [request.user] + demo_players
random.seed(42)
for p in players:
nodes.append({"id": p.display_name, "label": p.display_name})
for p, other in itertools.permutations(players, 2):
value = random.random()
if value > 0.5:
edges.append(
{
"id": f"{p.display_name}->{other.display_name}",
"source": p.display_name,
"target": other.display_name,
"size": max(value, 0.3),
"data": {
"relation": 2,
"origSize": max(value, 0.3),
"origFill": "#bed4ff",
},
}
)
elif value < 0.1:
edges.append(
{
"id": f"{p.display_name}-x>{other.display_name}",
"source": p.display_name,
"target": other.display_name,
"size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
)
G = nx.DiGraph()
G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from(
[
(
e["source"],
e["target"],
e["size"] if e["data"]["relation"] == 2 else -e["size"],
)
for e in edges
]
)
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}})
for node in nodes
]
if networkx_graph:
return G
return JSONResponse({"nodes": nodes, "edges": edges})
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == request.team_id) players = session.exec(
players = [t.players for t in session.exec(statement)][0] select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players: if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no players found in your team",
)
for p in players: for p in players:
player_map[p.id] = p.display_name player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name}) nodes.append({"id": p.display_name, "label": p.display_name})
@@ -116,11 +178,23 @@ def graph_json(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found" status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
) )
G = nx.DiGraph() G = nx.DiGraph()
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges]) G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from(
[
(
e["source"],
e["target"],
e["size"] if e["data"]["relation"] == 2 else -e["size"],
)
for e in edges
]
)
in_degrees = G.in_degree(weight="weight") in_degrees = G.in_degree(weight="weight")
nodes = [ nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
] ]
if networkx_graph:
return G
return JSONResponse({"nodes": nodes, "edges": edges}) return JSONResponse({"nodes": nodes, "edges": edges})
@@ -220,14 +294,33 @@ async def render_sociogram(params: Params):
def mvp( def mvp(
request: Annotated[ request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
): ):
ranks = dict() ranks = dict()
if request.team_id == 42:
random.seed(42)
players = [request.user] + demo_players
for p in players:
random.shuffle(players)
for i, p in enumerate(players):
ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1]
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == request.team_id) players = session.exec(
players = [t.players for t in session.exec(statement)][0] select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players: if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players} player_map = {p.id: p.display_name for p in players}
@@ -267,8 +360,12 @@ async def turnout(
): ):
player_map = {} player_map = {}
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == request.team_id) players = session.exec(
players = [t.players for t in session.exec(statement)][0] select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players: if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
for p in players: for p in players:

27
cutt/demo.py Normal file
View File

@@ -0,0 +1,27 @@
import random
from cutt.db import Player
names = [
"August",
"Beate",
"Ceasar",
"Daedalus",
"Elli",
"Ford P.",
"Gabriel",
"Hugo",
"Ivar Johansson",
"Jürgen Gordon Malinauskas",
]
demo_players = [
Player.model_validate(
{
"id": i,
"display_name": name,
"username": name.lower().replace(" ", "").replace(".", ""),
"number": str(random.randint(0, 100)),
"email": name.lower().replace(" ", "").replace(".", "") + "@example.org",
}
)
for i, name in enumerate(names)
]

View File

@@ -1,6 +1,6 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from cutt.db import Player, Team, Chemistry, MVPRanking, engine from cutt.db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import ( from sqlmodel import (
@@ -14,6 +14,7 @@ from cutt.security import (
get_current_active_user, get_current_active_user,
login_for_access_token, login_for_access_token,
logout, logout,
register,
set_first_password, set_first_password,
) )
from cutt.player import player_router from cutt.player import player_router
@@ -53,7 +54,7 @@ def list_teams():
team_router = APIRouter( team_router = APIRouter(
prefix="/team", prefix="/teams",
dependencies=[Security(get_current_active_user, scopes=["admin"])], dependencies=[Security(get_current_active_user, scopes=["admin"])],
tags=["team"], tags=["team"],
) )
@@ -75,6 +76,8 @@ def submit_mvps(
mvps: MVPRanking, mvps: MVPRanking,
user: Annotated[Player, Depends(get_current_active_user)], user: Annotated[Player, Depends(get_current_active_user)],
): ):
if mvps.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == mvps.user: if user.id == mvps.user:
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == mvps.team) statement = select(Team).where(Team.id == mvps.team)
@@ -120,6 +123,8 @@ def get_mvps(
def submit_chemistry( def submit_chemistry(
chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)] chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)]
): ):
if chemistry.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == chemistry.user: if user.id == chemistry.user:
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == chemistry.team) statement = select(Team).where(Team.id == chemistry.team)
@@ -177,6 +182,20 @@ api_router.include_router(team_router, dependencies=[Depends(get_current_active_
api_router.include_router(analysis_router) api_router.include_router(analysis_router)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"]) api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/register", endpoint=register, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"]) api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])
app.include_router(api_router) app.include_router(api_router)
app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
# app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
@app.get("/")
async def root():
return FileResponse("dist/index.html")
@app.exception_handler(404)
async def exception_404_handler(request, exc):
return FileResponse("dist/index.html")
app.mount("/", StaticFiles(directory="dist"), name="ui")

View File

@@ -1,20 +1,134 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Security from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from sqlmodel import Session, select from sqlmodel import Session, select
from cutt.db import Player, Team, engine from cutt.db import Player, PlayerTeamLink, Team, engine
from cutt.security import change_password, get_current_active_user, read_player_me from cutt.security import (
TeamScopedRequest,
change_password,
get_current_active_user,
read_player_me,
verify_team_scope,
)
from cutt.demo import demo_players
P = Player P = Player
player_router = APIRouter(prefix="/player", tags=["player"]) player_router = APIRouter(prefix="/player", tags=["player"])
def add_player(player: P): class PlayerRequest(BaseModel):
display_name: str
username: str
number: str
email: str
class AddPlayerRequest(PlayerRequest): ...
DEMO_TEAM_REQUEST = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DEMO Team, nothing happens",
)
def add_player(
r: AddPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
with Session(engine) as session: with Session(engine) as session:
session.add(player) if session.exec(select(P).where(P.username == r.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="username not available"
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.display_name == r.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == request.team_id)).one()
new_player = Player(
username=r.username,
display_name=r.display_name,
email=r.email if r.email else None,
number=r.number,
disabled=False,
teams=[team],
)
session.add(new_player)
session.commit() session.commit()
return PlainTextResponse(f"added {new_player.display_name}")
class ModifyPlayerRequest(PlayerRequest):
id: int
def modify_player(
r: ModifyPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
if request.team_id == 42:
raise DEMO_TEAM_REQUEST
with Session(engine) as session:
player = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.id == r.id, P.username == r.username)
).one_or_none()
if player:
player.display_name = r.display_name.strip()
player.number = r.number.strip()
player.email = r.email.strip()
session.add(player)
session.commit()
return PlainTextResponse("modification successful")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no such player found in your team",
)
class DisablePlayerRequest(BaseModel):
player_id: int
def disable_player(
r: DisablePlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
if request.team_id == 42:
raise DEMO_TEAM_REQUEST
with Session(engine) as session:
player = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.id == r.player_id)
).one_or_none()
if player:
player.disabled = True
session.add(player)
session.commit()
return PlainTextResponse(f"disabled {player.display_name}")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no such player found in your team",
)
def add_player_to_team(player_id: int, team_id: int): def add_player_to_team(player_id: int, team_id: int):
@@ -42,33 +156,71 @@ async def list_all_players():
return session.exec(select(P)).all() return session.exec(select(P)).all()
async def list_players(team_id: int): async def list_players(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
if team_id == 42:
return [
user.model_dump(
include={"id", "display_name", "username", "number", "email"}
)
] + demo_players
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == team_id) current_user = session.exec(
players = [t.players for t in session.exec(statement)][0] select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
).one_or_none()
if not current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="you're not in this team",
)
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False)
).all()
if players: if players:
return [ return [
player.model_dump(include={"id", "display_name", "number"}) player.model_dump(
include={"id", "display_name", "username", "number", "email"}
)
for player in players for player in players
if not player.disabled
] ]
def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]): def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
with Session(engine) as session: with Session(engine) as session:
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0] return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0] + [
{"country": "nowhere", "id": 42, "location": "everywhere", "name": "DEMO"}
]
player_router.add_api_route( player_router.add_api_route(
"/add", "/{team_id}",
endpoint=add_player, endpoint=add_player,
methods=["POST"], methods=["POST"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],
) )
player_router.add_api_route( player_router.add_api_route(
"/list/{team_id}", "/{team_id}",
endpoint=modify_player,
methods=["PUT"],
)
player_router.add_api_route(
"/{team_id}",
endpoint=disable_player,
methods=["DELETE"],
)
player_router.add_api_route(
"/{team_id}/list",
endpoint=list_players, endpoint=list_players,
methods=["GET"], methods=["GET"],
dependencies=[Depends(get_current_active_user)],
) )
player_router.add_api_route( player_router.add_api_route(
"/list", "/list",
@@ -77,7 +229,7 @@ player_router.add_api_route(
dependencies=[Security(get_current_active_user, scopes=["admin"])], dependencies=[Security(get_current_active_user, scopes=["admin"])],
) )
player_router.add_api_route( player_router.add_api_route(
"/add/{player_id}/{team_id}", "/add/{team_id}/{player_id}",
endpoint=add_player_to_team, endpoint=add_player_to_team,
methods=["GET"], methods=["GET"],
dependencies=[Security(get_current_active_user, scopes=["admin"])], dependencies=[Security(get_current_active_user, scopes=["admin"])],

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, ValidationError
import jwt import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select from sqlmodel import Session, select
from cutt.db import TokenDB, engine, Player from cutt.db import PlayerTeamLink, Team, TokenDB, engine, Player
from fastapi.security import ( from fastapi.security import (
OAuth2PasswordBearer, OAuth2PasswordBearer,
OAuth2PasswordRequestForm, OAuth2PasswordRequestForm,
@@ -16,6 +16,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
P = Player
class Config(BaseSettings): class Config(BaseSettings):
secret_key: str = "" secret_key: str = ""
@@ -168,6 +170,8 @@ class TeamScopedRequest(BaseModel):
async def verify_team_scope( async def verify_team_scope(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)] team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
): ):
if team_id == 42:
return TeamScopedRequest(user=user, team_id=team_id)
allowed_scopes = set(user.scopes.split()) allowed_scopes = set(user.scopes.split())
if f"team:{team_id}" not in allowed_scopes: if f"team:{team_id}" not in allowed_scopes:
raise HTTPException( raise HTTPException(
@@ -208,72 +212,94 @@ async def logout(response: Response):
return {"message": "Successfully logged out"} return {"message": "Successfully logged out"}
def generate_one_time_token(username): def set_password_token(username: str):
user = get_user(username) user = get_user(username)
if user: if user:
expire = timedelta(days=7) expire = timedelta(days=30)
token = create_access_token( token = create_access_token(
data={"sub": username, "name": user.display_name}, data={
"sub": "set password",
"username": username,
"name": user.display_name,
},
expires_delta=expire, expires_delta=expire,
) )
return token return token
def register_token(team_id: int):
with Session(engine) as session:
team = session.exec(select(Team).where(Team.id == team_id)).one()
if team:
expire = timedelta(days=30)
token = create_access_token(
data={"sub": "register", "team_id": team_id, "name": team.name},
expires_delta=expire,
)
return token
def verify_one_time_token(token: str):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate token",
)
with Session(engine) as session:
token_in_db = session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == False)
).one_or_none()
if token_in_db:
try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
return payload
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="access token expired",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
elif session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token already used",
)
else:
raise credentials_exception
def invalidate_one_time_token(token: str):
with Session(engine) as session:
token_in_db = session.exec(select(TokenDB).where(TokenDB.token == token)).one()
token_in_db.used = True
session.add(token_in_db)
session.commit()
class FirstPassword(BaseModel): class FirstPassword(BaseModel):
token: str token: str
password: str password: str
async def set_first_password(req: FirstPassword): async def set_first_password(req: FirstPassword):
credentials_exception = HTTPException( payload = verify_one_time_token(req.token)
status_code=status.HTTP_401_UNAUTHORIZED, action: str = payload.get("sub")
detail="Could not validate token", if action != "set password":
) raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
username: str = payload.get("username")
with Session(engine) as session: with Session(engine) as session:
token_in_db = session.exec( user = get_user(username)
select(TokenDB) if user:
.where(TokenDB.token == req.token) user.hashed_password = get_password_hash(req.password)
.where(TokenDB.used == False) session.add(user)
).one_or_none() session.commit()
if token_in_db: invalidate_one_time_token(req.token)
credentials_exception = HTTPException( return Response("password set successfully", status_code=status.HTTP_200_OK)
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate token",
)
try:
payload = jwt.decode(req.token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token expired",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
user = get_user(username)
if user:
user.hashed_password = get_password_hash(req.password)
session.add(user)
token_in_db.used = True
session.add(token_in_db)
session.commit()
return Response(
"Password set successfully", status_code=status.HTTP_200_OK
)
elif session.exec(
select(TokenDB)
.where(TokenDB.token == req.token)
.where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token already used",
)
else:
raise credentials_exception
class ChangedPassword(BaseModel): class ChangedPassword(BaseModel):
@@ -295,17 +321,74 @@ async def change_password(
session.add(user) session.add(user)
session.commit() session.commit()
return PlainTextResponse( return PlainTextResponse(
"Password changed successfully", "password changed successfully",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
media_type="text/plain", media_type="text/plain",
) )
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Wrong password", detail="wrong password",
) )
class RegisterRequest(BaseModel):
token: str
team_id: int
display_name: str
username: str
password: str
email: str | None
number: str | None
async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "register":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
team_id: int = payload.get("team_id")
if team_id != req.team_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong team",
)
with Session(engine) as session:
if session.exec(select(P).where(P.username == req.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username exists",
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.display_name == req.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == team_id)).one()
new_player = Player(
username=req.username,
hashed_password=get_password_hash(req.password),
display_name=req.display_name,
email=req.email if req.email else None,
number=req.number,
disabled=False,
teams=[team],
)
session.add(new_player)
session.commit()
# invalidate_one_time_token(req.token)
return PlainTextResponse(f"added {new_player.display_name}")
async def read_player_me( async def read_player_me(
current_user: Annotated[Player, Depends(get_current_active_user)], current_user: Annotated[Player, Depends(get_current_active_user)],
): ):

View File

@@ -471,7 +471,6 @@ button {
} }
} }
.networkroute { .networkroute {
z-index: 3; z-index: 3;
position: absolute; position: absolute;
@@ -479,6 +478,69 @@ button {
left: 48px; left: 48px;
} }
/*========TEAM PANEL========*/
.team-panel {
max-width: 800px;
padding: 1em;
border: 3px solid black;
box-shadow: 8px 8px black;
margin: 1em;
input {
max-width: 300px;
margin: 0.2em auto;
}
}
.team-player {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.4em;
margin: 4px;
padding: 0.2em 0.5em;
&:hover {
background-color: #36c8;
}
&.new-player {
background-color: #3838;
}
&.disable-player {
background-color: #e338;
}
}
.new-player-inputs {
display: flex;
flex-direction: column;
margin: auto;
div {
display: grid;
grid-template-columns: 20ch auto;
@media only screen and (max-width: 768px) {
grid-template-columns: auto;
place-items: center;
}
label {
text-align: left;
width: 20ch;
margin: auto 1em;
}
input {
width: 90%;
margin: 4px 0;
}
}
}
@keyframes blink { @keyframes blink {
0% { 0% {

View File

@@ -9,6 +9,7 @@ import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart"; import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword"; import { SetPassword } from "./SetPassword";
import { ThemeProvider } from "./ThemeProvider"; import { ThemeProvider } from "./ThemeProvider";
import TeamPanel from "./TeamPanel";
const Maintenance = () => { const Maintenance = () => {
return ( return (
@@ -33,10 +34,11 @@ function App() {
<Header /> <Header />
<Routes> <Routes>
<Route index element={<Rankings />} /> <Route index element={<Rankings />} />
<Route path="/network" element={<GraphComponent />} /> <Route path="network" element={<GraphComponent />} />
<Route path="/analysis" element={<Analysis />} /> <Route path="analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} /> <Route path="mvp" element={<MVPChart />} />
<Route path="/changepassword" element={<SetPassword />} /> <Route path="changepassword" element={<SetPassword />} />
<Route path="team" element={<TeamPanel />} />
</Routes> </Routes>
<Footer /> <Footer />
</SessionProvider> </SessionProvider>

View File

@@ -44,7 +44,7 @@ const UserInfo = (user: User, teams: TeamState | undefined) => {
> >
{teams.teams.map((team, index) => ( {teams.teams.map((team, index) => (
<li> <li>
{teams.activeTeam === index ? <b>{team.name}</b> : team.name} ( {<b>{team.name}</b>} (
{team.location || team.country || "location unknown"}) {team.location || team.country || "location unknown"})
</li> </li>
))} ))}
@@ -148,11 +148,10 @@ export default function Avatar() {
return ( return (
<> <>
<div className="avatars"> <div className="avatars" style={{ display: user ? "block" : "none" }}>
<div <div
className="avatar" className="avatar"
onContextMenu={handleMenuClick} onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => { onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) { if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose(); handleMenuClose();

View File

@@ -4,10 +4,11 @@ import { useSession } from "./Session";
export default function Footer() { export default function Footer() {
const location = useLocation(); const location = useLocation();
const { user } = useSession(); const { user, teams } = useSession();
return ( return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}> <footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
{user?.scopes.split(" ").includes("analysis") && ( {(user?.scopes.split(" ").includes("analysis") ||
teams?.activeTeam === 42) && (
<div className="navbar"> <div className="navbar">
<Link to="/"> <Link to="/">
<span>Form</span> <span>Form</span>
@@ -20,6 +21,10 @@ export default function Footer() {
<Link to="/mvp"> <Link to="/mvp">
<span>MVP</span> <span>MVP</span>
</Link> </Link>
<span>|</span>
<Link to="/team">
<span>Team</span>
</Link>
</div> </div>
)} )}
<p className="grey extra-margin"> <p className="grey extra-margin">

View File

@@ -114,7 +114,7 @@ export const Login = ({ onLogin }: LoginProps) => {
{visible ? <Eye /> : <EyeSlash />} {visible ? <Eye /> : <EyeSlash />}
</div> </div>
</div> </div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div> {error && <span style={{ color: "red" }}>{error}</span>}
<button type="submit" value="login" style={{ fontSize: "small" }}> <button type="submit" value="login" style={{ fontSize: "small" }}>
login login
</button> </button>

View File

@@ -3,6 +3,7 @@ import { apiAuth } from "./api";
import { PlayerRanking } from "./types"; import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart"; import RaceChart from "./RaceChart";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { useNavigate } from "react-router";
const MVPChart = () => { const MVPChart = () => {
let initialData = {} as PlayerRanking[]; let initialData = {} as PlayerRanking[];
@@ -10,7 +11,13 @@ const MVPChart = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showStd, setShowStd] = useState(false); const [showStd, setShowStd] = useState(false);
const { teams } = useSession(); const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);

View File

@@ -11,6 +11,7 @@ import {
} from "reagraph"; } from "reagraph";
import { customTheme } from "./NetworkTheme"; import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { useNavigate } from "react-router";
interface NetworkData { interface NetworkData {
nodes: GraphNode[]; nodes: GraphNode[];
@@ -45,7 +46,13 @@ export const GraphComponent = () => {
const [likes, setLikes] = useState(2); const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false); const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false); const [mutuality, setMutuality] = useState(false);
const { teams } = useSession(); const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);

View File

@@ -16,7 +16,7 @@ const determineNiceWidth = (width: number) => {
const RaceChart: FC<RaceChartProps> = ({ players, std }) => { const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth)); const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight); //const [height, setHeight] = useState(window.innerHeight);
const height = Math.max(3, players.length) * 40; const height = (players.length + 1) * 40;
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {

View File

@@ -7,7 +7,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs"; import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api"; import { apiAuth, loadPlayers, User } from "./api";
import { TeamState, useSession } from "./Session"; import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types"; import { Chemistry, MVPRanking } from "./types";
import TabController from "./TabController"; import TabController from "./TabController";
@@ -307,23 +307,10 @@ export default function Rankings() {
const { user, teams } = useSession(); const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null); const [players, setPlayers] = useState<User[] | null>(null);
async function loadPlayers() {
if (teams) {
try {
const data = await apiAuth(
`player/list/${teams?.activeTeam}`,
null,
"GET"
);
setPlayers(data as User[]);
} catch (error) {
console.error(error);
}
}
}
useEffect(() => { useEffect(() => {
loadPlayers(); if (teams) {
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [user, teams]); }, [user, teams]);
const tabs = [ const tabs = [

View File

@@ -62,8 +62,10 @@ export function SessionProvider(props: SessionProviderProps) {
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
loadTeam();
}, []); }, []);
useEffect(() => {
loadTeam();
}, [user]);
function onLogin(user: User) { function onLogin(user: User) {
setUser(user); setUser(user);

View File

@@ -1,17 +1,29 @@
import { jwtDecode, JwtPayload } from "jwt-decode"; import { jwtDecode, JwtPayload } from "jwt-decode";
import { useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { apiAuth, baseUrl } from "./api"; import { apiAuth, baseUrl, User } from "./api";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons"; import { Eye, EyeSlash } from "./Icons";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { relative } from "path";
import Header from "./Header";
interface SetPassToken extends JwtPayload { interface PassToken extends JwtPayload {
username: string;
name: string; name: string;
team_id: number;
}
enum Mode {
register = "register",
set = "set password",
change = "change password",
} }
export const SetPassword = () => { export const SetPassword = () => {
const [mode, setMode] = useState<Mode>();
const [name, setName] = useState("after getting your token."); const [name, setName] = useState("after getting your token.");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [teamID, setTeamID] = useState<number>();
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordr, setPasswordr] = useState(""); const [passwordr, setPasswordr] = useState("");
@@ -19,36 +31,22 @@ export const SetPassword = () => {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const newPlayerTemplate = {
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [player, setPlayer] = useState(newPlayerTemplate);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useSession(); const { user } = useSession();
useEffect(() => {
if (user) {
setUsername(user.username);
setName(user.display_name);
} else {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<SetPassToken>(token);
if (payload.name) setName(payload.name);
else if (payload.sub) setName(payload.sub);
else setName("Mr. I-have-no Token");
payload.sub && setUsername(payload.sub);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}
}, []);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (password === passwordr) { if (password === passwordr) {
setLoading(true); setLoading(true);
if (user) { if (mode === Mode.change) {
//====CHANGING PASSWORD====
const resp = await apiAuth( const resp = await apiAuth(
"player/change_password", "player/change_password",
{ current_password: currentPassword, new_password: password }, { current_password: currentPassword, new_password: password },
@@ -60,7 +58,8 @@ export const SetPassword = () => {
setError(resp); setError(resp);
setTimeout(() => navigate("/"), 2000); setTimeout(() => navigate("/"), 2000);
} }
} else { } else if (mode === Mode.set) {
//====SETTING PASSWORD====
const req = new Request(`${baseUrl}api/set_password`, { const req = new Request(`${baseUrl}api/set_password`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -92,106 +91,240 @@ export const SetPassword = () => {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
} }
} else if (mode === Mode.register) {
//====REGISTER NEW USER====
const req = new Request(`${baseUrl}api/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...player,
team_id: teamID,
token: token,
password: password,
}),
});
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
setLoading(false);
if (resp.ok) {
console.log(resp);
navigate("/", {
replace: true,
state: { username: player.username, password: password },
});
}
if (!resp.ok) {
const { detail } = await resp.json();
if (detail) setError(detail);
else setError("unauthorized");
throw new Error("Unauthorized");
}
} }
} else setError("passwords are not the same"); } else setError("passwords are not the same");
} }
return ( useEffect(() => {
if (user) {
setUsername(user.username);
setName(user.display_name);
setMode(Mode.change);
} else {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<PassToken>(token);
console.log(payload);
switch (payload.sub) {
case "register":
setMode(Mode.register);
if (payload.team_id) setTeamID(payload.team_id);
break;
case "set password":
setMode(Mode.set);
if (payload.username) setUsername(payload.username);
break;
}
if (payload.name) setName(payload.name);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}
}, []);
let header: ReactNode;
switch (mode) {
case Mode.change:
header = <h2>change your password, {name}</h2>;
break;
case Mode.set:
header = (
<>
<Header />
<h2>set your password, {name}</h2>
</>
);
break;
case Mode.register:
header = (
<>
<Header />
<h2>
register as a member of <i>{name}</i>
</h2>
</>
);
}
let textInputs: ReactNode;
switch (mode) {
case Mode.change:
textInputs = (
<div>
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="current password"
minLength={8}
value={currentPassword}
required
onChange={(evt) => {
setError("");
setCurrentPassword(evt.target.value);
}}
/>
<hr style={{ margin: "8px" }} />
</div>
);
break;
case Mode.register:
textInputs = (
<div className="new-player-inputs">
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
}}
/>
</div>
<div>
<label>username</label>
<input
type="text"
required
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
}}
/>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
}}
/>
</div>
<hr style={{ margin: "8px" }} />
</div>
);
break;
}
let passwordInputs = (
<> <>
{user ? ( <div>
<h2> change your password </h2> <input
) : ( type={visible ? "text" : "password"}
<h2> id="password"
set your password, name="password"
<br /> placeholder="password"
{name} minLength={8}
</h2> value={password}
)} required
{!user && username && ( onChange={(evt) => {
<span> setError("");
your username is: <i>{username}</i> setPassword(evt.target.value);
</span> }}
)} />
</div>
<div>
<input
type={visible ? "text" : "password"}
id="password-repeat"
name="password-repeat"
placeholder="repeat password"
minLength={8}
value={passwordr}
required
onChange={(evt) => {
setError("");
setPasswordr(evt.target.value);
}}
/>
</div>
</>
);
return mode ? (
<>
{header}
<hr style={{ width: "100%" }} />
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}} }}
> >
<div {textInputs}
style={{ {passwordInputs}
marginLeft: "48px",
marginRight: "8px",
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
{user && (
<div>
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="current password"
minLength={8}
value={currentPassword}
required
onChange={(evt) => {
setError("");
setCurrentPassword(evt.target.value);
}}
/>
<hr
style={{
margin: "8px",
borderStyle: "inset",
display: "block",
}}
/>
</div>
)}
<div>
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</div>
<div>
<input
type={visible ? "text" : "password"}
id="password-repeat"
name="password-repeat"
placeholder="repeat password"
minLength={8}
value={passwordr}
required
onChange={(evt) => {
setError("");
setPasswordr(evt.target.value);
}}
/>
</div>
</div>
<div <div
style={{ style={{
background: "unset", background: "unset",
fontSize: "xx-large", fontSize: "medium",
cursor: "pointer", cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}} }}
onClick={() => setVisible(!visible)} onClick={() => setVisible(!visible)}
> >
{visible ? <Eye /> : <EyeSlash />} {visible ? <Eye /> : <EyeSlash />} show passwords
</div> </div>
</div> </div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div> <div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
@@ -201,5 +334,7 @@ export const SetPassword = () => {
{loading && <span className="loader" />} {loading && <span className="loader" />}
</form> </form>
</> </>
) : (
<span className="loader" />
); );
}; };

209
src/TeamPanel.tsx Normal file
View File

@@ -0,0 +1,209 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, loadPlayers, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
const TeamPanel = () => {
const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
const newPlayerTemplate = {
id: 0,
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [players, setPlayers] = useState<User[] | null>(null);
const [player, setPlayer] = useState(newPlayerTemplate);
useEffect(() => {
if (teams) {
setError({ ok: true, message: "" });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [teams]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (teams) {
if (player.id === 0) {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "POST");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}
}
}
async function handleDisable(e: FormEvent) {
e.preventDefault();
if (teams && player.id !== 0) {
var confirmation = confirm("are you sure?");
if (confirmation) {
const r = await apiAuth(
`player/${teams?.activeTeam}`,
{ player_id: player.id },
"DELETE"
);
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
setPlayer(newPlayerTemplate);
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}
}
}
if (teams && players) {
const activeTeam = teams.teams.filter(
(team) => team.id == teams?.activeTeam
)[0];
return (
<div className="team-panel">
<h1>{activeTeam.name}</h1>
<div>
<input type="text" value={activeTeam.location || ""} disabled />
<br />
<input type="text" value={activeTeam.country || ""} disabled />
<hr style={{ width: "100%" }} />
<h2>players</h2>
{players ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{players &&
players.map((p) => (
<button
className="team-player"
key={p.id}
onClick={() => {
setPlayer(p);
setError({ ok: true, message: "" });
}}
>
{p.display_name}
</button>
))}
<button
className="team-player new-player"
key="add-player"
onClick={() => {
setPlayer(newPlayerTemplate);
setError({ ok: true, message: "" });
}}
>
+
</button>
</div>
) : (
<span className="loader" />
)}
<hr style={{ width: "100%" }} />
<form className="new-player-inputs" onSubmit={handleSubmit}>
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>username</label>
<input
type="text"
required
disabled={player.id !== 0}
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div style={{ margin: "auto" }}>
{error?.message && (
<span
style={{
color: error.ok ? "green" : "red",
}}
>
{error.message}
</span>
)}
</div>
<div style={{ margin: "auto" }}>
<button className="team-player new-player">
{player.id === 0 ? "add player" : "modify player"}
</button>
</div>
{player.id !== 0 && (
<div style={{ margin: "auto" }}>
<button
className="team-player disable-player"
onClick={handleDisable}
>
remove player
</button>
</div>
)}
</form>
</div>
</div>
);
} else <span className="loader" />;
};
export default TeamPanel;

View File

@@ -47,9 +47,7 @@ export type User = {
id: number; id: number;
username: string; username: string;
display_name: string; display_name: string;
full_name: string;
email: string; email: string;
player_id: number;
number: string; number: string;
scopes: string; scopes: string;
}; };
@@ -78,6 +76,16 @@ export async function currentUser(): Promise<User> {
return resp.json() as Promise<User>; return resp.json() as Promise<User>;
} }
export async function loadPlayers(teamId: number) {
try {
const data = await apiAuth(`player/${teamId}/list`, null, "GET");
return data as User[];
} catch (error) {
console.error(error);
return null;
}
}
export type LoginRequest = { export type LoginRequest = {
username: string; username: string;
password: string; password: string;

View File

@@ -39,3 +39,8 @@ export interface Team {
location: string; location: string;
country: string; country: string;
} }
export type ErrorState = {
ok: boolean;
message: string;
};