Compare commits

..

24 Commits

Author SHA1 Message Date
1067b12be8
feat: adjust mvp function to new DB 2025-03-11 12:40:12 +01:00
c42231907d
feat: adjust Sociogram to new DB 2025-03-11 12:37:16 +01:00
95e66e5d73
feat: adjust graph_json 2025-03-11 12:34:45 +01:00
6d2bf057a5
feat: more robust context menu 2025-03-11 12:33:35 +01:00
b07c2fd8ab
fix: remove un-controlled checked warning 2025-03-11 11:57:58 +01:00
82ffa06a00
feat: view profile 2025-03-11 11:45:48 +01:00
00442be4b5
fix: context menu and dialog didn't play nice 2025-03-11 11:14:07 +01:00
26ee4b84a9
feat: update context menu to use references 2025-03-11 11:04:42 +01:00
aa3c3df5da
fix: enable all sub-routes 2025-03-11 10:35:39 +01:00
401ac316c1
feat: adjust Rankings to new auth + User 2025-03-11 10:34:58 +01:00
53fc8bb6e3
feat: add more User props 2025-03-11 10:34:39 +01:00
92a98064e5
feat: add jwt token handler 2025-03-11 10:34:18 +01:00
1773a9885a
fix: remove max-age seconds 2025-03-11 10:32:49 +01:00
9996752d94
fix: check location.state first 2025-03-11 08:42:44 +01:00
b386ee365f
feat: automatically switch to index and fill in newly given creds 2025-03-11 08:25:33 +01:00
045c26d258
feat: setup for setting first password 2025-03-11 08:12:29 +01:00
a37971ed86
feat: set first password page 2025-03-10 13:16:41 +01:00
f3e6382101
feat: set first password with token 2025-03-10 13:15:41 +01:00
59e2fc4502
feat: User -> Player 2025-03-10 11:24:03 +01:00
33c505fee4
feat: change DB, represent players by id 2025-03-10 11:23:19 +01:00
cfe2df01f7
feat: Cookie and Header auth 2025-03-09 18:47:22 +01:00
7580a4f1e6
feat: remove unnecessary info in User response 2025-03-09 16:53:11 +01:00
7bf35b65fb
fix: logout bug 2025-03-09 16:34:05 +01:00
d3f5c3cb82
feat: roll back refresh tokens, use single token only 2025-03-07 18:24:25 +01:00
15 changed files with 573 additions and 330 deletions

View File

@ -1,4 +1,3 @@
from datetime import datetime
import io import io
import base64 import base64
from fastapi import APIRouter from fastapi import APIRouter
@ -27,14 +26,13 @@ def sociogram_json():
nodes = [] nodes = []
necessary_nodes = set() necessary_nodes = set()
edges = [] edges = []
players = {}
with Session(engine) as session: with Session(engine) as session:
for p in session.exec(select(P)).fetchall(): for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "label": p.name}) nodes.append({"id": p.display_name, "label": p.display_name})
players[p.id] = p.display_name
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
.where(C.time > datetime(2025, 2, 1, 10))
.group_by(C.user)
.subquery()
) )
statement2 = select(C).join( statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
@ -42,13 +40,13 @@ def sociogram_json():
for c in session.exec(statement2): for c in session.exec(statement2):
# G.add_node(c.user) # G.add_node(c.user)
necessary_nodes.add(c.user) necessary_nodes.add(c.user)
for p in c.love: for p in [players[p_id] for p_id in c.love]:
# G.add_edge(c.user, p) # G.add_edge(c.user, p)
# p_id = session.exec(select(P.id).where(P.name == p)).one() # p_id = session.exec(select(P.id).where(P.name == p)).one()
necessary_nodes.add(p) necessary_nodes.add(p)
edges.append({"from": c.user, "to": p, "relation": "likes"}) edges.append({"from": players[c.user], "to": p, "relation": "likes"})
for p in c.hate: for p in [players[p_id] for p_id in c.hate]:
edges.append({"from": c.user, "to": p, "relation": "dislikes"}) edges.append({"from": players[c.user], "to": p, "relation": "dislikes"})
# nodes = [n for n in nodes if n["name"] in necessary_nodes] # nodes = [n for n in nodes if n["name"] in necessary_nodes]
return JSONResponse({"nodes": nodes, "edges": edges}) return JSONResponse({"nodes": nodes, "edges": edges})
@ -56,24 +54,25 @@ def sociogram_json():
def graph_json(): def graph_json():
nodes = [] nodes = []
edges = [] edges = []
players = {}
with Session(engine) as session: with Session(engine) as session:
for p in session.exec(select(P)).fetchall(): for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "label": p.name}) players[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name})
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
.where(C.time > datetime(2025, 2, 1, 10))
.group_by(C.user)
.subquery()
) )
statement2 = select(C).join( statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
) )
for c in session.exec(statement2): for c in session.exec(statement2):
for i, p in enumerate(c.love): user = players[c.user]
for i, p_id in enumerate(c.love):
p = players[p_id]
edges.append( edges.append(
{ {
"id": f"{c.user}->{p}", "id": f"{user}->{p}",
"source": c.user, "source": user,
"target": p, "target": p,
"size": max(1.0 - 0.1 * i, 0.3), "size": max(1.0 - 0.1 * i, 0.3),
"data": { "data": {
@ -83,11 +82,12 @@ def graph_json():
}, },
} }
) )
for p in c.hate: for p_id in c.hate:
p = players[p_id]
edges.append( edges.append(
{ {
"id": f"{c.user}-x>{p}", "id": f"{user}-x>{p}",
"source": c.user, "source": user,
"target": p, "target": p,
"size": 0.3, "size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"}, "data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
@ -107,13 +107,12 @@ def graph_json():
def sociogram_data(show: int | None = 2): def sociogram_data(show: int | None = 2):
G = nx.DiGraph() G = nx.DiGraph()
with Session(engine) as session: with Session(engine) as session:
players = {}
for p in session.exec(select(P)).fetchall(): for p in session.exec(select(P)).fetchall():
G.add_node(p.name) G.add_node(p.display_name)
players[p.id] = p.display_name
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
.where(C.time > datetime(2025, 2, 1, 10))
.group_by(C.user)
.subquery()
) )
statement2 = ( statement2 = (
select(C) select(C)
@ -122,10 +121,12 @@ def sociogram_data(show: int | None = 2):
) )
for c in session.exec(statement2): for c in session.exec(statement2):
if show >= 1: if show >= 1:
for i, p in enumerate(c.love): for i, p_id in enumerate(c.love):
p = players[p_id]
G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i) G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i)
if show <= 1: if show <= 1:
for i, p in enumerate(c.hate): for i, p_id in enumerate(c.hate):
p = players[p_id]
G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16) G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16)
return G return G
@ -201,17 +202,16 @@ async def render_sociogram(params: Params):
def mvp(): def mvp():
ranks = dict() ranks = dict()
with Session(engine) as session: with Session(engine) as session:
players = {p.id: p.display_name for p in session.exec(select(P)).fetchall()}
subquery = ( subquery = (
select(R.user, func.max(R.time).label("latest")) select(R.user, func.max(R.time).label("latest")).group_by(R.user).subquery()
.where(R.time > datetime(2025, 2, 8))
.group_by(R.user)
.subquery()
) )
statement2 = select(R).join( statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
) )
for r in session.exec(statement2): for r in session.exec(statement2):
for i, p in enumerate(r.mvps): for i, p_id in enumerate(r.mvps):
p = players[p_id]
ranks[p] = ranks.get(p, []) + [i + 1] ranks[p] = ranks.get(p, []) + [i + 1]
return [ return [
{ {

39
db.py
View File

@ -2,17 +2,22 @@ from datetime import datetime, timezone
from sqlmodel import ( from sqlmodel import (
ARRAY, ARRAY,
Column, Column,
Integer,
Relationship, Relationship,
SQLModel, SQLModel,
Field, Field,
create_engine, create_engine,
String,
) )
with open("db.secrets", "r") as f: with open("db.secrets", "r") as f:
db_secrets = f.readline().strip() db_secrets = f.readline().strip()
engine = create_engine(db_secrets, connect_args={"connect_timeout": 8}) engine = create_engine(
db_secrets,
pool_timeout=20,
pool_size=2,
connect_args={"connect_timeout": 8},
)
del db_secrets del db_secrets
@ -39,37 +44,33 @@ class Team(SQLModel, table=True):
class Player(SQLModel, table=True): class Player(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
name: str username: str = Field(default=None, unique=True)
display_name: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
hashed_password: str | None = None
number: str | None = None number: str | None = None
teams: list[Team] | None = Relationship( teams: list[Team] | None = Relationship(
back_populates="players", link_model=PlayerTeamLink back_populates="players", link_model=PlayerTeamLink
) )
scopes: str = ""
class Chemistry(SQLModel, table=True): class Chemistry(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)
user: str user: int | None = Field(default=None, foreign_key="player.id")
love: list[str] = Field(sa_column=Column(ARRAY(String))) hate: list[int] = Field(sa_column=Column(ARRAY(Integer)))
hate: list[str] = Field(sa_column=Column(ARRAY(String))) undecided: list[int] = Field(sa_column=Column(ARRAY(Integer)))
undecided: list[str] = Field(sa_column=Column(ARRAY(String))) love: list[int] = Field(sa_column=Column(ARRAY(Integer)))
class MVPRanking(SQLModel, table=True): class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)
user: str user: int | None = Field(default=None, foreign_key="player.id")
mvps: list[str] = Field(sa_column=Column(ARRAY(String))) mvps: list[int] = Field(sa_column=Column(ARRAY(Integer)))
class User(SQLModel, table=True):
username: str = Field(default=None, primary_key=True)
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
hashed_password: str | None = None
player_id: int | None = Field(default=None, foreign_key="player.id")
scopes: str = ""
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)

32
main.py
View File

@ -8,10 +8,13 @@ from sqlmodel import (
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router from analysis import analysis_router
from security import ( from security import (
change_password,
get_current_active_user, get_current_active_user,
login_for_access_token, login_for_access_token,
read_users_me, logout,
read_player_me,
read_own_items, read_own_items,
set_first_password,
) )
@ -52,7 +55,7 @@ def add_players(players: list[Player]):
def list_players(): def list_players():
with Session(engine) as session: with Session(engine) as session:
statement = select(Player).order_by(Player.name) statement = select(Player).order_by(Player.display_name)
return session.exec(statement).fetchall() return session.exec(statement).fetchall()
@ -64,21 +67,16 @@ def list_teams():
player_router = APIRouter(prefix="/player") player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"]) player_router.add_api_route("/list", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/items", endpoint=read_own_items, methods=["GET"])
player_router.add_api_route( player_router.add_api_route(
"/add", "/change_password", endpoint=change_password, methods=["POST"]
endpoint=add_player,
methods=["POST"],
dependencies=[Depends(get_current_active_user)],
) )
team_router = APIRouter(prefix="/team") team_router = APIRouter(prefix="/team")
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( team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
"/add",
endpoint=add_team,
methods=["POST"],
dependencies=[Depends(get_current_active_user)],
)
@app.post("/mvps/", status_code=status.HTTP_200_OK) @app.post("/mvps/", status_code=status.HTTP_200_OK)
@ -103,14 +101,16 @@ class SPAStaticFiles(StaticFiles):
return response return response
api_router.include_router(player_router) api_router.include_router(
api_router.include_router(team_router) player_router, dependencies=[Depends(get_current_active_user)]
)
api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)])
api_router.include_router( api_router.include_router(
analysis_router, analysis_router,
dependencies=[Security(get_current_active_user, scopes=["analysis"])], dependencies=[Security(get_current_active_user, scopes=["analysis"])],
) )
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("/users/me/", endpoint=read_users_me, methods=["GET"]) api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"]) 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")

View File

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"jwt-decode": "^4.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-sortablejs": "^6.1.4", "react-sortablejs": "^6.1.4",

View File

@ -1,11 +1,12 @@
from datetime import timedelta, timezone, datetime from datetime import timedelta, timezone, datetime
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException, Response, status from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
import jwt import jwt
from jwt.exceptions import InvalidTokenError from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select from sqlmodel import Session, select
from db import engine, User from db import engine, Player
from fastapi.security import ( from fastapi.security import (
OAuth2PasswordBearer, OAuth2PasswordBearer,
OAuth2PasswordRequestForm, OAuth2PasswordRequestForm,
@ -18,7 +19,7 @@ from sqlalchemy.exc import OperationalError
class Config(BaseSettings): class Config(BaseSettings):
secret_key: str = "" secret_key: str = ""
access_token_expire_minutes: int = 30 access_token_expire_minutes: int = 15
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore" env_file=".env", env_file_encoding="utf-8", extra="ignore"
) )
@ -29,7 +30,6 @@ config = Config()
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str
class TokenData(BaseModel): class TokenData(BaseModel):
@ -40,7 +40,23 @@ class TokenData(BaseModel):
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer( class CookieOAuth2(OAuth2PasswordBearer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
async def __call__(self, request: Request):
cookie_token = request.cookies.get("access_token")
if cookie_token:
return cookie_token
else:
header_token = await super().__call__(request)
if header_token:
return header_token
else:
raise HTTPException(status_code=401)
oauth2_scheme = CookieOAuth2(
tokenUrl="api/token", tokenUrl="api/token",
scopes={ scopes={
"analysis": "Access the results.", "analysis": "Access the results.",
@ -61,7 +77,7 @@ def get_user(username: str | None):
try: try:
with Session(engine) as session: with Session(engine) as session:
return session.exec( return session.exec(
select(User).where(User.username == username) select(Player).where(Player.username == username)
).one_or_none() ).one_or_none()
except OperationalError: except OperationalError:
return return
@ -81,14 +97,17 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15) expire = datetime.now(timezone.utc) + timedelta(
minutes=config.access_token_expire_minutes
)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256") encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256")
return encoded_jwt return encoded_jwt
async def get_current_user( async def get_current_user(
security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)] token: Annotated[str, Depends(oauth2_scheme)],
security_scopes: SecurityScopes,
): ):
if security_scopes.scopes: if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
@ -99,13 +118,23 @@ async def get_current_user(
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value}, headers={"WWW-Authenticate": authenticate_value},
) )
# access_token = request.cookies.get("access_token")
access_token = token
if not access_token:
raise credentials_exception
try: try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) payload = jwt.decode(access_token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub") username: str = payload.get("sub")
if username is None: if username is None:
raise credentials_exception raise credentials_exception
token_scopes = payload.get("scopes", []) token_scopes = payload.get("scopes", [])
token_data = TokenData(username=username, scopes=token_scopes) token_data = TokenData(username=username, scopes=token_scopes)
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token expired",
headers={"WWW-Authenticate": authenticate_value},
)
except (InvalidTokenError, ValidationError): except (InvalidTokenError, ValidationError):
raise credentials_exception raise credentials_exception
user = get_user(username=token_data.username) user = get_user(username=token_data.username)
@ -122,7 +151,7 @@ async def get_current_user(
async def get_current_active_user( async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[Player, Depends(get_current_user)],
): ):
if current_user.disabled: if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user") raise HTTPException(status_code=400, detail="Inactive user")
@ -139,26 +168,99 @@ async def login_for_access_token(
detail="Incorrect username or password", detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
access_token_expires = timedelta(minutes=config.access_token_expire_minutes)
allowed_scopes = set(user.scopes.split()) allowed_scopes = set(user.scopes.split())
requested_scopes = set(form_data.scopes) requested_scopes = set(form_data.scopes)
access_token = create_access_token( access_token = create_access_token(
data={"sub": user.username, "scopes": list(allowed_scopes)}, data={"sub": user.username, "scopes": list(allowed_scopes)}
expires_delta=access_token_expires,
) )
response.set_cookie( response.set_cookie(
"Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none" "access_token",
value=access_token,
httponly=True,
samesite="strict",
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token)
async def read_users_me( async def logout(response: Response):
current_user: Annotated[User, Depends(get_current_active_user)], response.set_cookie("access_token", "", expires=0, httponly=True, samesite="strict")
return {"message": "Successfully logged out"}
def generate_one_time_token(username):
user = get_user(username)
if user:
expire = timedelta(days=7)
token = create_access_token(
data={"sub": username, "name": user.display_name},
expires_delta=expire,
)
return token
class FirstPassword(BaseModel):
token: str
password: str
async def set_first_password(req: FirstPassword):
credentials_exception = HTTPException(
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:
with Session(engine) as session:
user.hashed_password = get_password_hash(req.password)
session.add(user)
session.commit()
return Response("Password set successfully", status_code=status.HTTP_200_OK)
async def change_password(
current_password: str,
new_password: str,
user: Annotated[Player, Depends(get_current_active_user)],
):
if (
new_password
and user.hashed_password
and verify_password(current_password, user.hashed_password)
):
with Session(engine) as session:
user.hashed_password = get_password_hash(new_password)
session.add(user)
session.commit()
return PlainTextResponse(
"Password changed successfully", status_code=status.HTTP_200_OK
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Wrong password",
)
async def read_player_me(
current_user: Annotated[Player, Depends(get_current_active_user)],
): ):
return current_user return current_user
async def read_own_items( async def read_own_items(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[Player, Depends(get_current_active_user)],
): ):
return [{"item_id": "Foo", "owner": current_user.username}] return [{"item_id": "Foo", "owner": current_user.username}]

View File

@ -237,6 +237,7 @@ h3 {
button, button,
.button { .button {
margin: 4px;
font-weight: bold; font-weight: bold;
font-size: large; font-size: large;
color: aliceblue; color: aliceblue;
@ -395,11 +396,27 @@ button,
.avatar { .avatar {
background-color: lightsteelblue; background-color: lightsteelblue;
padding: 2px 8px; padding: 3px 8px;
width: fit-content; width: fit-content;
margin: auto; border: 3px solid black;
margin: 0 auto 16px auto;
ul {
min-width: 100px;
}
} }
.user-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 16px;
div {
text-align: left;
}
}
.networkroute { .networkroute {
z-index: 3; z-index: 3;
position: absolute; position: absolute;

View File

@ -7,21 +7,29 @@ import { BrowserRouter, Routes, Route } from "react-router";
import { SessionProvider } from "./Session"; import { SessionProvider } from "./Session";
import { GraphComponent } from "./Network"; import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart"; import MVPChart from "./MVPChart";
import Avatar from "./Avatar"; import { SetPassword } from "./SetPassword";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<SessionProvider> <Routes>
<Header /> <Route path="/password" element={<SetPassword />} />
<Routes> <Route
<Route index element={<Rankings />} /> path="/*"
<Route path="/network" element={<GraphComponent />} /> element={
<Route path="/analysis" element={<Analysis />} /> <SessionProvider>
<Route path="/mvp" element={<MVPChart />} /> <Header />
</Routes> <Routes>
<Footer /> <Route index element={<Rankings />} />
</SessionProvider> <Route path="/network" element={<GraphComponent />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} />
</Routes>
<Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@ -1,34 +1,62 @@
import { MouseEventHandler, useEffect, useState } from "react"; import { createRef, MouseEventHandler, useEffect, useState } from "react";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { logout } from "./api"; import { User } from "./api";
interface ContextMenuItem { interface ContextMenuItem {
label: string; label: string;
onClick: () => void; onClick: () => void;
} }
const UserInfo = (user: User) => {
return (
<div className="user-info">
<div>
<b>username: </b>
</div>
<div>{user?.username}</div>
<div>
<b>display name: </b>
</div>
<div>{user?.display_name}</div>
<div>
<b>number: </b>
</div>
<div>{user?.number ? user?.number : "-"}</div>
<div>
<b>email: </b>
</div>
<div>{user?.email ? user?.email : "-"}</div>
</div>
);
};
export default function Avatar() { export default function Avatar() {
const { user, onLogout } = useSession(); const { user, onLogout } = useSession();
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
open: boolean; open: boolean;
allowOpen: boolean;
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
}>({ open: false, mouseX: 0, mouseY: 0 }); }>({ open: false, allowOpen: true, mouseX: 0, mouseY: 0 });
const contextMenuRef = createRef<HTMLUListElement>();
const avatarRef = createRef<HTMLDivElement>();
const contextMenuItems: ContextMenuItem[] = [ const contextMenuItems: ContextMenuItem[] = [
{ label: "View Profile", onClick: () => console.log("View Profile") }, { label: "View Profile", onClick: handleViewProfile },
{ label: "Edit Profile", onClick: () => console.log("Edit Profile") },
{ label: "Logout", onClick: onLogout }, { label: "Logout", onClick: onLogout },
]; ];
const handleMenuClick: MouseEventHandler<HTMLDivElement> = (event) => { const handleMenuClick: MouseEventHandler<HTMLDivElement> = (event) => {
if (!contextMenu.allowOpen) return;
event.preventDefault(); event.preventDefault();
setContextMenu({ setContextMenu({
open: !contextMenu.open, open: !contextMenu.open,
allowOpen: contextMenu.allowOpen,
mouseX: event.clientX + 4, mouseX: event.clientX + 4,
mouseY: event.clientY + 2, mouseY: event.clientY + 2,
}); });
}; };
useEffect(() => { useEffect(() => {
if (contextMenu.open) { if (contextMenu.open) {
document.addEventListener("click", handleCloseContextMenuOutside); document.addEventListener("click", handleCloseContextMenuOutside);
@ -40,30 +68,58 @@ export default function Avatar() {
const handleMenuClose = () => { const handleMenuClose = () => {
setContextMenu({ ...contextMenu, open: false }); setContextMenu({ ...contextMenu, open: false });
setContextMenu((prevContextMenu) => ({
...prevContextMenu,
allowOpen: false,
}));
setTimeout(() => {
setContextMenu((prevContextMenu) => ({
...prevContextMenu,
allowOpen: true,
}));
}, 100);
}; };
const handleCloseContextMenuOutside: MouseEventHandler<Document> = (
event const handleCloseContextMenuOutside: (event: MouseEvent) => void = (ev) => {
) => {
if ( if (
!event.target || !(
(!(event.target as Element).closest(".context-menu") && contextMenuRef.current?.contains(ev.target as Node) ||
!(event.target as Element).closest(".avatar")) avatarRef.current?.contains(ev.target as Node)
) { )
)
handleMenuClose(); handleMenuClose();
}
}; };
const [dialog, setDialog] = useState(<></>);
const dialogRef = createRef<HTMLDialogElement>();
function handleViewProfile() {
handleMenuClose();
if (user) {
dialogRef.current?.showModal();
setDialog(UserInfo(user));
}
}
return ( return (
<div <div
className="avatar" className="avatar"
onContextMenu={handleMenuClick} onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }} style={{ display: user ? "block" : "none" }}
onClick={handleMenuClick} onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();
} else {
handleMenuClick(event);
}
}}
ref={avatarRef}
> >
{user?.username} 👤 {user?.username}
{contextMenu.open && ( {contextMenu.open && (
<ul <ul
className="context-menu" className="context-menu"
ref={contextMenuRef}
style={{ style={{
zIndex: 3, zIndex: 3,
position: "absolute", position: "absolute",
@ -94,6 +150,16 @@ export default function Avatar() {
))} ))}
</ul> </ul>
)} )}
<dialog
id="AvatarDialog"
ref={dialogRef}
onClick={(event) => {
event.stopPropagation();
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</div> </div>
); );
} }

View File

@ -1,23 +1,27 @@
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { Link } from "react-router"; import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() { export default function Footer() {
const location = useLocation(); const location = useLocation();
const { user } = useSession();
return ( return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}> <footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
<div className="navbar"> {user?.scopes.split(" ").includes("analysis") && (
<Link to="/"> <div className="navbar">
<span>Form</span> <Link to="/">
</Link> <span>Form</span>
<span>|</span> </Link>
<Link to="/network"> <span>|</span>
<span>Trainer Analysis</span> <Link to="/network">
</Link> <span>Trainer Analysis</span>
<span>|</span> </Link>
<Link to="/mvp"> <span>|</span>
<span>MVP</span> <Link to="/mvp">
</Link> <span>MVP</span>
</div> </Link>
</div>
)}
<p className="grey extra-margin"> <p className="grey extra-margin">
something not working? something not working?
<br /> <br />

View File

@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { currentUser, login, User } from "./api"; import { currentUser, login, User } from "./api";
import Header from "./Header"; import Header from "./Header";
import { useLocation, useNavigate } from "react-router";
export interface LoginProps { export interface LoginProps {
onLogin: (user: User) => void; onLogin: (user: User) => void;
@ -9,12 +10,14 @@ export interface LoginProps {
export const Login = ({ onLogin }: LoginProps) => { export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<unknown>(null); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
async function doLogin() { async function doLogin() {
setLoading(true); setLoading(true);
setError(null); setError("");
const timeout = new Promise((r) => setTimeout(r, 1000)); const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User; let user: User;
try { try {
@ -22,7 +25,7 @@ export const Login = ({ onLogin }: LoginProps) => {
user = await currentUser(); user = await currentUser();
} catch (e) { } catch (e) {
await timeout; await timeout;
setError(e); setError("failed");
setLoading(false); setLoading(false);
return; return;
} }
@ -35,6 +38,16 @@ export const Login = ({ onLogin }: LoginProps) => {
doLogin(); doLogin();
} }
useEffect(() => {
if (location.state) {
const queryUsername = location.state.username;
const queryPassword = location.state.password;
if (queryUsername) setUsername(queryUsername);
if (queryPassword) setPassword(queryPassword);
navigate(location.pathname, { replace: true });
}
}, []);
return ( return (
<> <>
<Header /> <Header />
@ -47,7 +60,10 @@ export const Login = ({ onLogin }: LoginProps) => {
placeholder="username" placeholder="username"
required required
value={username} value={username}
onChange={(evt) => setUsername(evt.target.value)} onChange={(evt) => {
setError("");
setUsername(evt.target.value);
}}
/> />
</div> </div>
<div> <div>
@ -59,9 +75,13 @@ export const Login = ({ onLogin }: LoginProps) => {
minLength={8} minLength={8}
value={password} value={password}
required required
onChange={(evt) => setPassword(evt.target.value)} onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/> />
</div> </div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
<button type="submit" value="login" style={{ fontSize: "small" }}> <button type="submit" value="login" style={{ fontSize: "small" }}>
login login
</button> </button>

View File

@ -151,7 +151,7 @@ export const GraphComponent = () => {
<div className="controls"> <div className="controls">
<div className="control" onClick={handleMutuality}> <div className="control" onClick={handleMutuality}>
<div className="switch"> <div className="switch">
<input type="checkbox" checked={mutuality} /> <input type="checkbox" checked={mutuality} onChange={() => {}} />
<span className="slider round"></span> <span className="slider round"></span>
</div> </div>
<span>mutuality</span> <span>mutuality</span>
@ -160,7 +160,7 @@ export const GraphComponent = () => {
<div className="control" onClick={handleThreed}> <div className="control" onClick={handleThreed}>
<span>2D</span> <span>2D</span>
<div className="switch"> <div className="switch">
<input type="checkbox" checked={threed} /> <input type="checkbox" checked={threed} onChange={() => {}} />
<span className="slider round"></span> <span className="slider round"></span>
</div> </div>
<span>3D</span> <span>3D</span>
@ -192,7 +192,7 @@ export const GraphComponent = () => {
<div className="control" onClick={handlePopularity}> <div className="control" onClick={handlePopularity}>
<div className="switch"> <div className="switch">
<input type="checkbox" checked={popularity} /> <input type="checkbox" checked={popularity} onChange={() => {}} />
<span className="slider round"></span> <span className="slider round"></span>
</div> </div>
<span> <span>

View File

@ -1,12 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs"; import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import api, { baseUrl } from "./api"; import { apiAuth, User } from "./api";
import { useSession } from "./Session";
interface Player {
id: number;
name: string;
number: string | null;
}
type PlayerListProps = Partial<ReactSortableProps<any>> & { type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean; orderedList?: boolean;
@ -17,116 +12,44 @@ function PlayerList(props: PlayerListProps) {
<ReactSortable {...props} animation={200}> <ReactSortable {...props} animation={200}>
{props.list?.map((item, index) => ( {props.list?.map((item, index) => (
<div key={item.id} className="item"> <div key={item.id} className="item">
{props.orderedList ? index + 1 + ". " + item.name : item.name} {props.orderedList
? index + 1 + ". " + item.display_name
: item.display_name}
</div> </div>
))} ))}
</ReactSortable> </ReactSortable>
); );
} }
interface SelectUserProps {
user: Player[];
setUser: Dispatch<SetStateAction<Player[]>>;
players: Player[];
setPlayers: Dispatch<SetStateAction<Player[]>>;
}
export function SelectUser({
user,
setUser,
players,
setPlayers,
}: SelectUserProps) {
return (
<>
<div className="box user">
{user.length < 1 ? (
<>
<span>your name?</span>
<br /> <span className="grey hint">drag your name here</span>
</>
) : (
<>
<span
className="renew"
onClick={() => {
setUser([]);
}}
>
{" ✕"}
</span>
</>
)}
<PlayerList
list={user}
setList={setUser}
group={{
name: "user-shared",
put: function (to) {
return to.el.children.length < 1;
},
}}
className="dragbox"
/>
</div>
{user.length < 1 && (
<div className="box one">
<h2>🥏🏃</h2>
<ReactSortable
list={players}
setList={setPlayers}
group={{ name: "user-shared", pull: "clone" }}
className="dragbox reservoir"
animation={200}
>
{players.length < 1 ? (
<span className="loader"></span>
) : (
players.map((item, _) => (
<div key={"extra" + item.id} className="extra-margin">
<div key={item.id} className="item">
{item.name}
</div>
</div>
))
)}
</ReactSortable>
</div>
)}
</>
);
}
interface PlayerInfoProps { interface PlayerInfoProps {
user: Player[]; user: User;
players: Player[]; players: User[];
} }
export function Chemistry({ user, players }: PlayerInfoProps) { export function Chemistry({ user, players }: PlayerInfoProps) {
const index = players.indexOf(user[0]); const index = players.indexOf(user);
var otherPlayers = players.slice(); var otherPlayers = players.slice();
otherPlayers.splice(index, 1); otherPlayers.splice(index, 1);
const [playersLeft, setPlayersLeft] = useState<Player[]>([]); const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers); const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<Player[]>([]); const [playersRight, setPlayersRight] = useState<User[]>([]);
useEffect(() => {
setPlayersMiddle(otherPlayers);
}, [players]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
async function handleSubmit() { async function handleSubmit() {
const dialog = document.querySelector("dialog[id='ChemistryDialog']"); const dialog = document.querySelector("dialog[id='ChemistryDialog']");
(dialog as HTMLDialogElement).showModal(); (dialog as HTMLDialogElement).showModal();
if (user.length < 1) { setDialog("sending...");
setDialog("who are you?"); let left = playersLeft.map(({ display_name }) => display_name);
} else { let middle = playersMiddle.map(({ display_name }) => display_name);
setDialog("sending..."); let right = playersRight.map(({ display_name }) => display_name);
let _user = user.map(({ name }) => name)[0]; const data = { user: user, hate: left, undecided: middle, love: right };
let left = playersLeft.map(({ name }) => name); const response = await apiAuth("chemistry", data);
let middle = playersMiddle.map(({ name }) => name); response.ok ? setDialog("success!") : setDialog("try sending again");
let right = playersRight.map(({ name }) => name);
const data = { user: _user, hate: left, undecided: middle, love: right };
const response = await api("chemistry", data);
response.ok ? setDialog("success!") : setDialog("try sending again");
}
} }
return ( return (
@ -188,24 +111,23 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
} }
export function MVP({ user, players }: PlayerInfoProps) { export function MVP({ user, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players); const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]); const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
useEffect(() => {
setAvailablePlayers(players);
}, [players]);
async function handleSubmit() { async function handleSubmit() {
const dialog = document.querySelector("dialog[id='MVPDialog']"); const dialog = document.querySelector("dialog[id='MVPDialog']");
(dialog as HTMLDialogElement).showModal(); (dialog as HTMLDialogElement).showModal();
if (user.length < 1) { setDialog("sending...");
setDialog("who are you?"); let mvps = rankedPlayers.map(({ display_name }) => display_name);
} else { const data = { user: user, mvps: mvps };
setDialog("sending..."); const response = await apiAuth("mvps", data);
let _user = user.map(({ name }) => name)[0]; response.ok ? setDialog("success!") : setDialog("try sending again");
let mvps = rankedPlayers.map(({ name }) => name);
const data = { user: _user, mvps: mvps };
const response = await api("mvps", data);
response.ok ? setDialog("success!") : setDialog("try sending again");
}
} }
return ( return (
@ -267,24 +189,25 @@ export function MVP({ user, players }: PlayerInfoProps) {
} }
export default function Rankings() { export default function Rankings() {
const [user, setUser] = useState<Player[]>([]); const { user } = useSession();
const [players, setPlayers] = useState<Player[]>([]); const [players, setPlayers] = useState<User[]>([]);
const [openTab, setOpenTab] = useState("Chemistry"); const [openTab, setOpenTab] = useState("Chemistry");
async function loadPlayers() { async function loadPlayers() {
const response = await fetch(`${baseUrl}api/player/list`, { try {
method: "GET", const data = await apiAuth("player/list", null, "GET");
}); setPlayers(data as User[]);
const data = await response.json(); } catch (error) {
setPlayers(data as Player[]); console.error(error);
}
} }
useMemo(() => { useEffect(() => {
loadPlayers(); loadPlayers();
}, []); }, []);
useEffect(() => { useEffect(() => {
user.length === 1 && openPage(openTab, "aliceblue"); openPage(openTab, "aliceblue");
}, [user]); }, [user]);
function openPage(pageName: string, color: string) { function openPage(pageName: string, color: string) {
@ -314,37 +237,35 @@ export default function Rankings() {
return ( return (
<> <>
<SelectUser {...{ user, setUser, players, setPlayers }} /> <div className="container navbar">
{user.length === 1 && ( <button
<> className="tablink"
<div className="container navbar"> id="ChemistryButton"
<button onClick={() => openPage("Chemistry", "aliceblue")}
className="tablink" >
id="ChemistryButton" 🧪 Chemistry
onClick={() => openPage("Chemistry", "aliceblue")} </button>
> <button
🧪 Chemistry className="tablink"
</button> id="MVPButton"
<button onClick={() => openPage("MVP", "aliceblue")}
className="tablink" >
id="MVPButton" 🏆 MVP
onClick={() => openPage("MVP", "aliceblue")} </button>
> </div>
🏆 MVP
</button>
</div>
<span className="grey">assign as many or as few players as you want<br /> <span className="grey">
and don't forget to <b>submit</b> (💾) when you're done :)</span> assign as many or as few players as you want
<br />
and don't forget to <b>submit</b> (💾) when you're done :)
</span>
<div id="Chemistry" className="tabcontent"> <div id="Chemistry" className="tabcontent">
<Chemistry {...{ user, players }} /> {user && <Chemistry {...{ user, players }} />}
</div> </div>
<div id="MVP" className="tabcontent"> <div id="MVP" className="tabcontent">
<MVP {...{ user, players }} /> {user && <MVP {...{ user, players }} />}
</div> </div>
</>
)}
</> </>
); );
} }

View File

@ -59,11 +59,9 @@ export function SessionProvider(props: SessionProviderProps) {
setUser(null); setUser(null);
setErr({ message: "Logged out successfully" }); setErr({ message: "Logged out successfully" });
console.log("logged out."); console.log("logged out.");
setLoading(true); // Set loading to true
loadUser();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setErr(e); // Update the error state if logout fails setErr(e);
} }
} }
console.log("sanity", user); console.log("sanity", user);
@ -77,12 +75,7 @@ export function SessionProvider(props: SessionProviderProps) {
</> </>
); );
else if (err) { else if (err) {
if ((err as any).message === "Logged out successfully") { content = <Login onLogin={onLogin} />;
setTimeout(() => setErr(null), 1000);
content = <Login onLogin={onLogin} />;
} else {
content = <Login onLogin={onLogin} />;
}
} else } else
content = ( content = (
<sessionContext.Provider value={{ user, onLogout }}> <sessionContext.Provider value={{ user, onLogout }}>

126
src/SetPassword.tsx Normal file
View File

@ -0,0 +1,126 @@
import { InvalidTokenError, jwtDecode, JwtPayload } from "jwt-decode";
import { useEffect, useState } from "react";
import { baseUrl } from "./api";
import { redirect, useNavigate } from "react-router";
interface SetPassToken extends JwtPayload {
name: string;
}
export const SetPassword = () => {
const [name, setName] = useState("after getting your token.");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [passwordr, setPasswordr] = useState("");
const [token, setToken] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
useEffect(() => {
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) {
e.preventDefault();
if (password === passwordr) {
setLoading(true);
const req = new Request(`${baseUrl}api/set_password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ 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: username, password: password },
});
}
if (!resp.ok) {
if (resp.status === 401) {
resp.statusText
? setError(resp.statusText)
: setError("unauthorized");
throw new Error("Unauthorized");
}
}
} else setError("passwords are not the same");
}
return (
<>
<h2>
set your password,
<br />
{name}
</h2>
{username && (
<span>
your username is: <i>{username}</i>
</span>
)}
<form onSubmit={handleSubmit}>
<div>
<input
type="password"
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</div>
<div>
<input
type="password"
id="password-repeat"
name="password-repeat"
placeholder="repeat password"
minLength={8}
value={passwordr}
required
onChange={(evt) => {
setError("");
setPasswordr(evt.target.value);
}}
/>
</div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>
{loading && <span className="loader" />}
</form>
</>
);
};

View File

@ -1,22 +1,4 @@
export const baseUrl = import.meta.env.VITE_BASE_URL as string; export const baseUrl = import.meta.env.VITE_BASE_URL as string;
export const token = () => localStorage.getItem("access_token") as string;
export default async function api(path: string, data: any): Promise<any> {
const request = new Request(`${baseUrl}${path}/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
let response: Response;
try {
response = await fetch(request);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
return response;
}
export async function apiAuth( export async function apiAuth(
path: string, path: string,
@ -26,9 +8,9 @@ export async function apiAuth(
const req = new Request(`${baseUrl}api/${path}`, { const req = new Request(`${baseUrl}api/${path}`, {
method: method, method: method,
headers: { headers: {
Authorization: `Bearer ${token()} `,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include",
...(data && { body: JSON.stringify(data) }), ...(data && { body: JSON.stringify(data) }),
}); });
let resp: Response; let resp: Response;
@ -48,20 +30,23 @@ export async function apiAuth(
} }
export type User = { export type User = {
id: number;
username: string; username: string;
display_name: string;
full_name: string; full_name: string;
email: string; email: string;
player_id: number; player_id: number;
number: string;
scopes: string;
}; };
export async function currentUser(): Promise<User> { export async function currentUser(): Promise<User> {
if (!token()) throw new Error("you have no access token"); const req = new Request(`${baseUrl}api/player/me`, {
const req = new Request(`${baseUrl}api/users/me/`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token()} `,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include",
}); });
let resp: Response; let resp: Response;
try { try {
@ -83,12 +68,7 @@ export type LoginRequest = {
username: string; username: string;
password: string; password: string;
}; };
export type Token = {
access_token: string;
token_type: string;
};
// api.js
export const login = async (req: LoginRequest): Promise<void> => { export const login = async (req: LoginRequest): Promise<void> => {
try { try {
const response = await fetch(`${baseUrl}api/token`, { const response = await fetch(`${baseUrl}api/token`, {
@ -97,20 +77,24 @@ export const login = async (req: LoginRequest): Promise<void> => {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams(req).toString(), body: new URLSearchParams(req).toString(),
credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const token = (await response.json()) as Token;
if (token && token.access_token) {
localStorage.setItem("access_token", token.access_token);
} else {
console.log("Token not acquired");
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw e; // rethrow the error so it can be caught by the caller throw e; // rethrow the error so it can be caught by the caller
} }
}; };
export const logout = () => localStorage.removeItem("access_token"); export const logout = async () => {
try {
await fetch(`${baseUrl}api/logout`, {
method: "POST",
credentials: "include",
});
} catch (e) {
console.error(e);
}
};