Compare commits
11 Commits
6408a3fee1
...
5c76a60df1
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c76a60df1
|
|||
|
bb41513571
|
|||
|
aff3b9f7be
|
|||
|
be3cf4175e
|
|||
|
051202edc9
|
|||
|
12b42e4a46
|
|||
|
ed460f63d6
|
|||
|
7ec6a5b45f
|
|||
|
03134b2f03
|
|||
|
1b6ad04148
|
|||
|
0c65aae718
|
@@ -1 +0,0 @@
|
|||||||
VITE_BASE_URL=http://localhost:8000/
|
|
||||||
@@ -13,6 +13,7 @@ from cutt.analysis import analysis_router
|
|||||||
from cutt.mail import send_forgotten_password_link
|
from cutt.mail import send_forgotten_password_link
|
||||||
from cutt.security import (
|
from cutt.security import (
|
||||||
get_current_active_user,
|
get_current_active_user,
|
||||||
|
join_team_token,
|
||||||
login_for_access_token,
|
login_for_access_token,
|
||||||
logout,
|
logout,
|
||||||
register,
|
register,
|
||||||
@@ -59,6 +60,9 @@ team_router = APIRouter(
|
|||||||
)
|
)
|
||||||
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
|
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
|
||||||
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
|
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
|
||||||
|
team_router.add_api_route(
|
||||||
|
"/join_link/{team_id}", endpoint=join_team_token, methods=["GET"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
wrong_user_id_exception = HTTPException(
|
wrong_user_id_exception = HTTPException(
|
||||||
|
|||||||
103
cutt/player.py
103
cutt/player.py
@@ -10,6 +10,7 @@ from cutt.security import (
|
|||||||
change_password,
|
change_password,
|
||||||
get_current_active_user,
|
get_current_active_user,
|
||||||
read_player_me,
|
read_player_me,
|
||||||
|
verify_one_time_token,
|
||||||
verify_team_scope,
|
verify_team_scope,
|
||||||
)
|
)
|
||||||
from cutt.demo import demo_players
|
from cutt.demo import demo_players
|
||||||
@@ -19,12 +20,24 @@ P = Player
|
|||||||
player_router = APIRouter(prefix="/player", tags=["player"])
|
player_router = APIRouter(prefix="/player", tags=["player"])
|
||||||
|
|
||||||
|
|
||||||
|
def update_team_manager(scopes_str: str, team_id: int, state: bool = True):
|
||||||
|
scopes = set(scopes_str.split())
|
||||||
|
if state:
|
||||||
|
scopes.add(f"team:{team_id}")
|
||||||
|
else:
|
||||||
|
scopes.remove(f"team:{team_id}")
|
||||||
|
|
||||||
|
print("new scopestr", " ".join(scopes))
|
||||||
|
return " ".join(sorted(scopes))
|
||||||
|
|
||||||
|
|
||||||
class PlayerRequest(BaseModel):
|
class PlayerRequest(BaseModel):
|
||||||
display_name: str
|
display_name: str
|
||||||
username: str
|
username: str
|
||||||
gender: str | None
|
gender: str | None
|
||||||
number: str
|
number: str
|
||||||
email: str | None
|
email: str | None
|
||||||
|
is_manager: bool | None
|
||||||
|
|
||||||
|
|
||||||
class AddPlayerRequest(PlayerRequest): ...
|
class AddPlayerRequest(PlayerRequest): ...
|
||||||
@@ -96,6 +109,10 @@ def modify_player(
|
|||||||
player.number = r.number.strip()
|
player.number = r.number.strip()
|
||||||
player.gender = r.gender.strip() if r.gender else None
|
player.gender = r.gender.strip() if r.gender else None
|
||||||
player.email = r.email.strip() if r.email else None
|
player.email = r.email.strip() if r.email else None
|
||||||
|
if r.is_manager is not None:
|
||||||
|
player.scopes = update_team_manager(
|
||||||
|
player.scopes, request.team_id, r.is_manager
|
||||||
|
)
|
||||||
session.add(player)
|
session.add(player)
|
||||||
session.commit()
|
session.commit()
|
||||||
return PlainTextResponse("modification successful")
|
return PlainTextResponse("modification successful")
|
||||||
@@ -181,14 +198,46 @@ def add_player_to_team(player_id: int, team_id: int):
|
|||||||
player = session.exec(select(P).where(P.id == player_id)).one()
|
player = session.exec(select(P).where(P.id == player_id)).one()
|
||||||
team = session.exec(select(Team).where(Team.id == team_id)).one()
|
team = session.exec(select(Team).where(Team.id == team_id)).one()
|
||||||
if player and team:
|
if player and team:
|
||||||
team.players.append(player)
|
if player in team.players:
|
||||||
session.add(team)
|
return PlainTextResponse(
|
||||||
session.commit()
|
f"{player.display_name} ({player.username}) is already part of {team.name}"
|
||||||
return PlainTextResponse(
|
)
|
||||||
f"added {player.display_name} ({player.username}) to {team.name}"
|
else:
|
||||||
|
team.players.append(player)
|
||||||
|
session.add(team)
|
||||||
|
session.commit()
|
||||||
|
return PlainTextResponse(
|
||||||
|
f"added {player.display_name} ({player.username}) to {team.name}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="something went wrong",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
team_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def join_team(r: JoinRequest, user: Annotated[P, Depends(get_current_active_user)]):
|
||||||
|
payload = verify_one_time_token(r.token)
|
||||||
|
action: str = payload.get("sub")
|
||||||
|
if action != "join":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="wrong type of token.",
|
||||||
|
)
|
||||||
|
team_id: int = payload.get("team_id")
|
||||||
|
if team_id != r.team_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="wrong team",
|
||||||
|
)
|
||||||
|
return add_player_to_team(user.id, r.team_id)
|
||||||
|
|
||||||
|
|
||||||
def add_players(players: list[P]):
|
def add_players(players: list[P]):
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
for player in players:
|
for player in players:
|
||||||
@@ -202,7 +251,7 @@ async def list_all_players():
|
|||||||
|
|
||||||
|
|
||||||
async def list_players(
|
async def list_players(
|
||||||
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
|
team_id: int, user: Annotated[P, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
if team_id == 42:
|
if team_id == 42:
|
||||||
return [
|
return [
|
||||||
@@ -212,6 +261,8 @@ async def list_players(
|
|||||||
] + demo_players
|
] + demo_players
|
||||||
|
|
||||||
allowed_scopes = set(user.scopes.split())
|
allowed_scopes = set(user.scopes.split())
|
||||||
|
team_manager_scope = f"team:{team_id}"
|
||||||
|
is_team_manager = team_manager_scope in allowed_scopes
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
current_user = session.exec(
|
current_user = session.exec(
|
||||||
@@ -220,7 +271,7 @@ async def list_players(
|
|||||||
.join(Team)
|
.join(Team)
|
||||||
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
|
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
|
||||||
).one_or_none()
|
).one_or_none()
|
||||||
if not current_user and f"team:{team_id}" not in allowed_scopes:
|
if not current_user and not is_team_manager:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="you're not in this team",
|
detail="you're not in this team",
|
||||||
@@ -233,21 +284,26 @@ async def list_players(
|
|||||||
.where(Team.id == team_id, P.disabled == False)
|
.where(Team.id == team_id, P.disabled == False)
|
||||||
.order_by(P.display_name)
|
.order_by(P.display_name)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if players:
|
if players:
|
||||||
return [
|
players_dump = []
|
||||||
player.model_dump(
|
for player in players:
|
||||||
include={
|
if not player.disabled:
|
||||||
"id",
|
player_dump = player.model_dump(
|
||||||
"display_name",
|
include={
|
||||||
"username",
|
"id",
|
||||||
"gender",
|
"display_name",
|
||||||
"number",
|
"username",
|
||||||
"email",
|
"gender",
|
||||||
}
|
"number",
|
||||||
)
|
}
|
||||||
for player in players
|
)
|
||||||
if not player.disabled
|
if is_team_manager:
|
||||||
]
|
player_dump["email"] = player.email
|
||||||
|
if team_manager_scope in player.scopes:
|
||||||
|
player_dump["is_manager"] = True
|
||||||
|
players_dump.append(player_dump)
|
||||||
|
return players_dump
|
||||||
|
|
||||||
|
|
||||||
def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
|
def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
|
||||||
@@ -290,6 +346,11 @@ player_router.add_api_route(
|
|||||||
endpoint=remove_player_from_team,
|
endpoint=remove_player_from_team,
|
||||||
methods=["DELETE"],
|
methods=["DELETE"],
|
||||||
)
|
)
|
||||||
|
player_router.add_api_route(
|
||||||
|
"/{team_id}/join",
|
||||||
|
endpoint=join_team,
|
||||||
|
methods=["POST"],
|
||||||
|
)
|
||||||
player_router.add_api_route(
|
player_router.add_api_route(
|
||||||
"/{team_id}/list",
|
"/{team_id}/list",
|
||||||
endpoint=list_players,
|
endpoint=list_players,
|
||||||
|
|||||||
@@ -225,16 +225,19 @@ def set_password_token(user: Player):
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def register_token(team_id: int):
|
def join_team_token(team_id: int):
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
team = session.exec(select(Team).where(Team.id == team_id)).one()
|
team = session.exec(select(Team).where(Team.id == team_id)).one()
|
||||||
if team:
|
if team:
|
||||||
expire = timedelta(days=30)
|
expire = timedelta(days=30)
|
||||||
token = create_access_token(
|
token = create_access_token(
|
||||||
data={"sub": "register", "team_id": team_id, "name": team.name},
|
data={"sub": "join", "team_id": team_id, "name": team.name},
|
||||||
expires_delta=expire,
|
expires_delta=expire,
|
||||||
)
|
)
|
||||||
return token
|
if token:
|
||||||
|
session.add(TokenDB(token=token))
|
||||||
|
session.commit()
|
||||||
|
return PlainTextResponse(f"https://cutt.0124816.xyz/join?token={token}")
|
||||||
|
|
||||||
|
|
||||||
def verify_one_time_token(token: str):
|
def verify_one_time_token(token: str):
|
||||||
@@ -344,7 +347,7 @@ class RegisterRequest(BaseModel):
|
|||||||
async def register(req: RegisterRequest):
|
async def register(req: RegisterRequest):
|
||||||
payload = verify_one_time_token(req.token)
|
payload = verify_one_time_token(req.token)
|
||||||
action: str = payload.get("sub")
|
action: str = payload.get("sub")
|
||||||
if action != "register":
|
if action != "join":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="wrong type of token.",
|
detail="wrong type of token.",
|
||||||
|
|||||||
@@ -1,58 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="50" viewBox="0 0 80 50"><ellipse cx="40" cy="25" rx="20.263" ry="20.633" style="fill:#c7d6f1;fill-opacity:1;stroke:#36c;stroke-width:8.73336"/><path d="M0 17.67h80v14.66H0Z" style="fill:#000;stroke-width:3.56018;paint-order:stroke fill markers"/><text xml:space="preserve" x="39.788" y="29.819" style="font-style:normal;font-variant:normal;font-weight:700;font-stretch:normal;font-size:14px;line-height:1;font-family:Sans;-inkscape-font-specification:"Sans Bold";text-align:center;letter-spacing:2.83px;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#fff;stroke:#fff;stroke-width:.655;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"><tspan x="39.788" y="29.819" style="font-style:normal;font-variant:normal;font-weight:700;font-stretch:normal;font-size:14px;font-family:Sans;-inkscape-font-specification:"Sans Bold";letter-spacing:2.83px;fill:#fff;stroke:#fff;stroke-width:.655;stroke-dasharray:none;stroke-opacity:1">CUTT</tspan></text></svg>
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="50"
|
|
||||||
viewBox="0 0 80 50"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1"
|
|
||||||
sodipodi:docname="cutt.svg"
|
|
||||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
|
||||||
inkscape:export-filename="cutt.svg"
|
|
||||||
inkscape:export-xdpi="362.84"
|
|
||||||
inkscape:export-ydpi="362.84"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#000000"
|
|
||||||
borderopacity="0.25"
|
|
||||||
inkscape:showpageshadow="2"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1"
|
|
||||||
inkscape:zoom="12.413459"
|
|
||||||
inkscape:cx="38.909381"
|
|
||||||
inkscape:cy="55.786224"
|
|
||||||
inkscape:window-width="1408"
|
|
||||||
inkscape:window-height="1727"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="0"
|
|
||||||
inkscape:current-layer="svg1" />
|
|
||||||
<ellipse
|
|
||||||
cx="40"
|
|
||||||
cy="25"
|
|
||||||
rx="20.262579"
|
|
||||||
ry="20.632982"
|
|
||||||
style="fill:#c7d6f1;fill-opacity:1;stroke:#3366cc;stroke-width:8.73336"
|
|
||||||
id="ellipse1" />
|
|
||||||
<path
|
|
||||||
d="m -3.4e-4,17.669765 h 80.00068 v 14.66047 H -3.4e-4 Z"
|
|
||||||
style="fill:#000000;stroke-width:3.56018;paint-order:stroke fill markers"
|
|
||||||
id="path1" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
x="39.788086"
|
|
||||||
y="29.819336"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14px;line-height:1;font-family:Sans;-inkscape-font-specification:'Sans Bold';text-align:center;letter-spacing:2.83px;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#ffffff;stroke:#ffffff;stroke-width:0.655;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
|
|
||||||
id="text1"><tspan
|
|
||||||
x="39.788086"
|
|
||||||
y="29.819336"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14px;font-family:Sans;-inkscape-font-specification:'Sans Bold';letter-spacing:2.83px;fill:#ffffff;stroke:#ffffff;stroke-width:0.655;stroke-dasharray:none;stroke-opacity:1"
|
|
||||||
id="tspan1">CUTT</tspan></text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -10,6 +10,7 @@ import MVPChart from "./MVPChart";
|
|||||||
import { SetPassword } from "./SetPassword";
|
import { SetPassword } from "./SetPassword";
|
||||||
import { Register } from "./Register";
|
import { Register } from "./Register";
|
||||||
import { ForgotPassword } from "./Login";
|
import { ForgotPassword } from "./Login";
|
||||||
|
import { Join } from "./JoinTeam";
|
||||||
|
|
||||||
const Maintenance = () => {
|
const Maintenance = () => {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +41,7 @@ function App() {
|
|||||||
<Route path="network" element={<GraphComponent />} />
|
<Route path="network" element={<GraphComponent />} />
|
||||||
<Route path="mvp" element={<MVPChart />} />
|
<Route path="mvp" element={<MVPChart />} />
|
||||||
<Route path="team" element={<TeamPanel />} />
|
<Route path="team" element={<TeamPanel />} />
|
||||||
|
<Route path="join" element={<Join />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Footer />
|
<Footer />
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiAuth, User } from "./api";
|
|||||||
import { TeamState, useSession } from "./Session";
|
import { TeamState, useSession } from "./Session";
|
||||||
import TabController from "./TabController";
|
import TabController from "./TabController";
|
||||||
import { Chemistry, MVPRanking, PlayerType } from "./types";
|
import { Chemistry, MVPRanking, PlayerType } from "./types";
|
||||||
|
import Loading from "./Loading";
|
||||||
|
|
||||||
type PlayerListProps = Partial<ReactSortableProps<any>> & {
|
type PlayerListProps = Partial<ReactSortableProps<any>> & {
|
||||||
orderedList?: boolean;
|
orderedList?: boolean;
|
||||||
@@ -103,7 +104,7 @@ function TypeDnD({ user, teams, players }: PlayerInfoProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleGet();
|
handleGet();
|
||||||
}, [players]);
|
}, [players, teams]);
|
||||||
|
|
||||||
const [dialog, setDialog] = useState("dialog");
|
const [dialog, setDialog] = useState("dialog");
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
@@ -244,7 +245,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
|
|||||||
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
|
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
|
||||||
activeTeam && setMixed(activeTeam.mixed);
|
activeTeam && setMixed(activeTeam.mixed);
|
||||||
handleGet();
|
handleGet();
|
||||||
}, [players]);
|
}, [players, teams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleGet();
|
handleGet();
|
||||||
@@ -300,9 +301,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="container">
|
Loading
|
||||||
<progress className="progress is-primary" max="100"></progress>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="columns container is-mobile is-1-mobile">
|
<div className="columns container is-mobile is-1-mobile">
|
||||||
<div className="column">
|
<div className="column">
|
||||||
@@ -365,7 +364,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleGet();
|
handleGet();
|
||||||
}, [players]);
|
}, [players, teams]);
|
||||||
|
|
||||||
const [dialog, setDialog] = useState("dialog");
|
const [dialog, setDialog] = useState("dialog");
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
@@ -422,9 +421,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="container">
|
Loading
|
||||||
<progress className="progress is-primary" max="100"></progress>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="columns container is-multiline is-mobile is-1-mobile">
|
<div className="columns container is-multiline is-mobile is-1-mobile">
|
||||||
<div className="column is-full is-flex is-justify-content-center">
|
<div className="column is-full is-flex is-justify-content-center">
|
||||||
@@ -526,9 +523,7 @@ export default function Rankings() {
|
|||||||
<TypeDnD {...{ user, teams, players }} />
|
<TypeDnD {...{ user, teams, players }} />
|
||||||
</TabController>
|
</TabController>
|
||||||
) : (
|
) : (
|
||||||
<div className="container">
|
Loading
|
||||||
<progress className="progress is-primary" max="100"></progress>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Link } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { useSession } from "./Session";
|
import { useSession } from "./Session";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { user, teams, setTeams, players, onLogout } = useSession();
|
const { user, teams, setTeams, players, onLogout } = useSession();
|
||||||
const [burgerActive, setBurgerActive] = useState(false);
|
const [burgerActive, setBurgerActive] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const pages = [
|
||||||
|
{ name: "Sociogram", path: "/network" },
|
||||||
|
{ name: "MVP", path: "/mvp" },
|
||||||
|
{ name: "Team", path: "/team" },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<nav className="navbar" role="navigation" aria-label="main navigation">
|
<nav className="navbar" role="navigation" aria-label="main navigation">
|
||||||
<div className="navbar-brand">
|
<div className="navbar-brand">
|
||||||
@@ -36,29 +42,23 @@ export default function Header() {
|
|||||||
className={"navbar-menu" + (burgerActive ? " is-active" : "")}
|
className={"navbar-menu" + (burgerActive ? " is-active" : "")}
|
||||||
id="navbar"
|
id="navbar"
|
||||||
>
|
>
|
||||||
{user?.scopes.includes(`team:${teams?.activeTeam}`) && (
|
{(user?.scopes.includes(`team:${teams?.activeTeam}`) ||
|
||||||
|
teams?.activeTeam === 42) && (
|
||||||
<div className="navbar-start">
|
<div className="navbar-start">
|
||||||
<Link
|
{pages.map((p) => (
|
||||||
onClick={() => setBurgerActive(false)}
|
<Link
|
||||||
className="navbar-item"
|
onClick={() => setBurgerActive(false)}
|
||||||
to="/network"
|
className={
|
||||||
>
|
"navbar-item" +
|
||||||
<span>Sociogram</span>
|
(location.pathname === p.path
|
||||||
</Link>
|
? " has-text-weight-extrabold"
|
||||||
<Link
|
: "")
|
||||||
onClick={() => setBurgerActive(false)}
|
}
|
||||||
className="navbar-item"
|
to={p.path}
|
||||||
to="/mvp"
|
>
|
||||||
>
|
<span>{p.name}</span>
|
||||||
<span>MVP</span>
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
<Link
|
|
||||||
onClick={() => setBurgerActive(false)}
|
|
||||||
className="navbar-item"
|
|
||||||
to="/team"
|
|
||||||
>
|
|
||||||
<span>Team</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="navbar-end">
|
<div className="navbar-end">
|
||||||
|
|||||||
64
frontend/src/JoinTeam.tsx
Normal file
64
frontend/src/JoinTeam.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
import { apiAuth, PassToken } from "./api";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { UsersIcon } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useSession } from "./Session";
|
||||||
|
|
||||||
|
export const Join = () => {
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [teamName, setTeamName] = useState("");
|
||||||
|
const [teamID, setTeamID] = useState<number>();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { teams, setTeams } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get("token");
|
||||||
|
if (token) {
|
||||||
|
setToken(token);
|
||||||
|
try {
|
||||||
|
const payload = jwtDecode<PassToken>(token);
|
||||||
|
if (payload.sub === "join") {
|
||||||
|
if (payload.team_id) setTeamID(payload.team_id);
|
||||||
|
} else {
|
||||||
|
setError("not a valid token for joining");
|
||||||
|
}
|
||||||
|
if (payload.name) setTeamName(payload.name);
|
||||||
|
} catch (InvalidTokenError) {
|
||||||
|
setError("not a valid token");
|
||||||
|
}
|
||||||
|
} else setError("no token found");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
|
const r = await apiAuth(
|
||||||
|
`player/${teamID}/join`,
|
||||||
|
{ token: token, team_id: teamID },
|
||||||
|
"POST"
|
||||||
|
);
|
||||||
|
if (r.detail) setError(r.detail);
|
||||||
|
else {
|
||||||
|
setTeams({ ...teams, activeTeam: teamID });
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section is-medium">
|
||||||
|
<div className="container is-max-tablet has-text-centered">
|
||||||
|
<h1 className="title">Join "{teamName}"</h1>
|
||||||
|
<div className="field is-grouped is-grouped-centered">
|
||||||
|
<button className="button is-dark is-large" onClick={handleJoin}>
|
||||||
|
<span className="icon">
|
||||||
|
<UsersIcon />
|
||||||
|
</span>
|
||||||
|
<span> join team</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="help is-danger">{error}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
6
frontend/src/Loading.tsx
Normal file
6
frontend/src/Loading.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const Loading = (
|
||||||
|
<div className="container">
|
||||||
|
<progress className="progress is-primary" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
export default Loading;
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { baseUrl, currentUser, login, User } from "./api";
|
import { baseUrl, currentUser, login, User } from "./api";
|
||||||
import Header from "./Header";
|
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { Eye, EyeSlash } from "./Icons";
|
import Loading from "./Loading";
|
||||||
|
|
||||||
export interface LoginProps {
|
export interface LoginProps {
|
||||||
onLogin: (user: User) => void;
|
onLogin: (user: User) => void;
|
||||||
@@ -101,7 +100,7 @@ export const Login = ({ onLogin }: LoginProps) => {
|
|||||||
login
|
login
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{loading && <span className="loader" />}
|
{loading && Loading}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
import { useNavigate } from "react-router";
|
||||||
|
import Loading from "./Loading";
|
||||||
|
|
||||||
const MVPChart = () => {
|
const MVPChart = () => {
|
||||||
let initialData = {} as PlayerRanking[][];
|
let initialData = {} as PlayerRanking[][];
|
||||||
@@ -54,7 +55,7 @@ const MVPChart = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [teams]);
|
}, [teams]);
|
||||||
|
|
||||||
if (loading) return <span className="loader" />;
|
if (loading) return Loading;
|
||||||
else if (error) return <span>{error}</span>;
|
else if (error) return <span>{error}</span>;
|
||||||
else
|
else
|
||||||
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);
|
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export const GraphComponent = () => {
|
|||||||
<br />
|
<br />
|
||||||
<i>Ctrl</i> or <i>Shift</i>
|
<i>Ctrl</i> or <i>Shift</i>
|
||||||
</p>
|
</p>
|
||||||
|
<br />
|
||||||
<p>drag to pan/rotate</p>
|
<p>drag to pan/rotate</p>
|
||||||
<hr className="has-background-info" />
|
<hr className="has-background-info" />
|
||||||
<p>popularity is meassured by rank-weighted in-degree</p>
|
<p>popularity is meassured by rank-weighted in-degree</p>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { jwtDecode, JwtPayload } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { baseUrl, Gender } from "./api";
|
import { baseUrl, Gender, PassToken } from "./api";
|
||||||
import { useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
interface PassToken extends JwtPayload {
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
team_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Register = () => {
|
export const Register = () => {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -23,16 +17,17 @@ export const Register = () => {
|
|||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const token = params.get("token");
|
const token = params.get("token");
|
||||||
if (token) {
|
if (token) {
|
||||||
setToken(token);
|
setToken(token);
|
||||||
try {
|
try {
|
||||||
const payload = jwtDecode<PassToken>(token);
|
const payload = jwtDecode<PassToken>(token);
|
||||||
if (payload.sub === "register") {
|
if (payload.sub === "join") {
|
||||||
if (payload.team_id) setTeamID(payload.team_id);
|
if (payload.team_id) setTeamID(payload.team_id);
|
||||||
} else {
|
} else {
|
||||||
setError("not a valid token for registration");
|
setError("not a valid token for registration");
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
|
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
|
||||||
import { Login } from "./Login";
|
import { Login } from "./Login";
|
||||||
import Header from "./Header";
|
|
||||||
import { Team } from "./types";
|
import { Team } from "./types";
|
||||||
|
import Loading from "./Loading";
|
||||||
|
import { Link, useLocation } from "react-router";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
export interface SessionProviderProps {
|
export interface SessionProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -43,26 +45,31 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [teams, setTeams] = useState<TeamState | null>(null);
|
const [teams, setTeams] = useState<TeamState | null>(null);
|
||||||
const [players, setPlayers] = useState<User[] | null>(null);
|
const [players, setPlayers] = useState<User[] | null>(null);
|
||||||
const [err, setErr] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
function loadUser() {
|
function loadUser() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
currentUser()
|
currentUser()
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
setUser(user);
|
setUser(user);
|
||||||
setErr(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setErr(err);
|
setError(err);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTeam() {
|
async function loadTeam() {
|
||||||
const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
|
const loaded_teams: Team[] = await apiAuth("player/me/teams", null, "GET");
|
||||||
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
|
if (loaded_teams)
|
||||||
|
setTeams({
|
||||||
|
teams: loaded_teams,
|
||||||
|
activeTeam: teams?.activeTeam || loaded_teams[0].id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadPlayers() {
|
async function reloadPlayers() {
|
||||||
@@ -81,30 +88,25 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
|
|
||||||
function onLogin(user: User) {
|
function onLogin(user: User) {
|
||||||
setUser(user);
|
setUser(user);
|
||||||
setErr(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
try {
|
try {
|
||||||
logout();
|
logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setErr({ message: "Logged out successfully" });
|
setError({ message: "Logged out successfully" });
|
||||||
console.log("logged out.");
|
console.log("logged out.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setErr(e);
|
setError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let content: ReactNode;
|
let content: ReactNode;
|
||||||
if (loading || (!err && !user))
|
if (loading || (!error && !user))
|
||||||
content = (
|
content = <section className="section is-medium">{Loading}</section>;
|
||||||
<>
|
else if (error) {
|
||||||
<Header />
|
|
||||||
<span className="loader" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
else if (err) {
|
|
||||||
content = (
|
content = (
|
||||||
<section className="section is-medium">
|
<section className="section is-medium">
|
||||||
<div className="container is-max-tablet">
|
<div className="container is-max-tablet">
|
||||||
@@ -118,6 +120,19 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{location.pathname === "/join" && (
|
||||||
|
<div className="notification is-warning">
|
||||||
|
<div className="icon-text">
|
||||||
|
<span className="icon">
|
||||||
|
<TriangleAlert />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
If you don't already have an account,{" "}
|
||||||
|
<Link to={`/register${location.search}`}>register here</Link>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Login onLogin={onLogin} />
|
<Login onLogin={onLogin} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useSession } from "./Session";
|
|||||||
import { ErrorState } from "./types";
|
import { ErrorState } from "./types";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import Calendar from "./Calendar";
|
import Calendar from "./Calendar";
|
||||||
|
import { Info, Star, StarHalf, StarOff, UserPen } from "lucide-react";
|
||||||
|
import Loading from "./Loading";
|
||||||
|
|
||||||
const TeamPanel = () => {
|
const TeamPanel = () => {
|
||||||
const { user, teams, players, reloadPlayers } = useSession();
|
const { user, teams, players, reloadPlayers } = useSession();
|
||||||
@@ -20,6 +22,7 @@ const TeamPanel = () => {
|
|||||||
gender: undefined,
|
gender: undefined,
|
||||||
number: "",
|
number: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
is_manager: false,
|
||||||
} as User;
|
} as User;
|
||||||
const [error, setError] = useState<ErrorState>();
|
const [error, setError] = useState<ErrorState>();
|
||||||
const [player, setPlayer] = useState(newPlayerTemplate);
|
const [player, setPlayer] = useState(newPlayerTemplate);
|
||||||
@@ -95,7 +98,12 @@ const TeamPanel = () => {
|
|||||||
setError({ ok: true, message: "" });
|
setError({ ok: true, message: "" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.display_name}
|
<span>{p.display_name}</span>
|
||||||
|
{p.is_manager && (
|
||||||
|
<span className="icon">
|
||||||
|
<Star size={16} fill="gold" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
@@ -110,7 +118,7 @@ const TeamPanel = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="loader" />
|
Loading
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,7 +204,7 @@ const TeamPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
email <small>(optional)</small>
|
email <small>(optional, but helpful)</small>
|
||||||
</label>
|
</label>
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<input
|
<input
|
||||||
@@ -209,18 +217,52 @@ const TeamPanel = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error?.message && (
|
</div>
|
||||||
<p
|
<div className="field">
|
||||||
className={
|
<div className="control">
|
||||||
"help" + (error.ok ? " is-success" : " is-danger")
|
<label className="label">
|
||||||
}
|
manager{" "}
|
||||||
|
<span className="tooltip">
|
||||||
|
<Info size={16} />
|
||||||
|
<span className="tooltiptext notification is-primary is-light has-text-centered">
|
||||||
|
managers are able to see the analyses and modify
|
||||||
|
the players
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className={"button"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPlayer({
|
||||||
|
...player,
|
||||||
|
is_manager: !player.is_manager,
|
||||||
|
});
|
||||||
|
setError({ ok: true, message: "" });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{error.message}
|
<span className="icon">
|
||||||
</p>
|
{player.is_manager ? (
|
||||||
)}
|
<Star fill="gold" />
|
||||||
|
) : (
|
||||||
|
<StarOff />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>{player.is_manager ? "yes" : "no"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field is-grouped is-grouped-centered">
|
{error?.message && (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
"help" + (error.ok ? " is-success" : " is-danger")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="field is-grouped is-grouped-multiline is-grouped-centered">
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
"button is-light" +
|
"button is-light" +
|
||||||
@@ -250,6 +292,6 @@ const TeamPanel = () => {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else <span className="loader" />;
|
} else Loading;
|
||||||
};
|
};
|
||||||
export default TeamPanel;
|
export default TeamPanel;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { JwtPayload } from "jwt-decode";
|
||||||
import { useSession } from "./Session";
|
import { useSession } from "./Session";
|
||||||
|
|
||||||
export const baseUrl = "";
|
export const baseUrl = "";
|
||||||
@@ -53,6 +54,7 @@ export type User = {
|
|||||||
number: string;
|
number: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
scopes: string;
|
scopes: string;
|
||||||
|
is_manager: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function currentUser(): Promise<User> {
|
export async function currentUser(): Promise<User> {
|
||||||
@@ -124,3 +126,9 @@ export const logout = async () => {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface PassToken extends JwtPayload {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
team_id: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,3 +20,23 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip text */
|
||||||
|
.tooltiptext {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 10rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the tooltip text on hover */
|
||||||
|
.tooltip:hover .tooltiptext {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user