Compare commits

..

3 Commits

Author SHA1 Message Date
2a396457aa
chore: remove unused stuff 2025-03-11 15:43:36 +01:00
34c030c1e9
feat: adjust submission function to new DB 2025-03-11 13:52:54 +01:00
6eb2563068
feat: implement one-time token 2025-03-11 13:37:23 +01:00
11 changed files with 155 additions and 90 deletions

8
db.py
View File

@ -73,4 +73,12 @@ class MVPRanking(SQLModel, table=True):
mvps: list[int] = Field(sa_column=Column(ARRAY(Integer))) mvps: list[int] = Field(sa_column=Column(ARRAY(Integer)))
class TokenDB(SQLModel, table=True):
token: str = Field(index=True, primary_key=True)
used: bool | None = False
updated_at: datetime | None = Field(
default_factory=utctime, sa_column_kwargs={"onupdate": utctime}
)
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, FastAPI, Security, status from fastapi import APIRouter, Depends, FastAPI, Security
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine from db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import ( from sqlmodel import (
@ -79,18 +80,20 @@ 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"])
@app.post("/mvps/", status_code=status.HTTP_200_OK) @api_router.post("/mvps", dependencies=[Depends(get_current_active_user)])
def submit_mvps(mvps: MVPRanking): def submit_mvps(mvps: MVPRanking):
with Session(engine) as session: with Session(engine) as session:
session.add(mvps) session.add(mvps)
session.commit() session.commit()
return JSONResponse("success!")
@app.post("/chemistry/", status_code=status.HTTP_200_OK) @api_router.post("/chemistry", dependencies=[Depends(get_current_active_user)])
def submit_chemistry(chemistry: Chemistry): def submit_chemistry(chemistry: Chemistry):
with Session(engine) as session: with Session(engine) as session:
session.add(chemistry) session.add(chemistry)
session.commit() session.commit()
return JSONResponse("success!")
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):

View File

@ -19,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/node": "^22.13.10",
"@types/react": "18.3.18", "@types/react": "18.3.18",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.5",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",

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 db import engine, Player from db import TokenDB, engine, Player
from fastapi.security import ( from fastapi.security import (
OAuth2PasswordBearer, OAuth2PasswordBearer,
OAuth2PasswordRequestForm, OAuth2PasswordRequestForm,
@ -178,6 +178,7 @@ async def login_for_access_token(
value=access_token, value=access_token,
httponly=True, httponly=True,
samesite="strict", samesite="strict",
max_age=config.access_token_expire_minutes * 60,
) )
return Token(access_token=access_token) return Token(access_token=access_token)
@ -204,6 +205,17 @@ class FirstPassword(BaseModel):
async def set_first_password(req: FirstPassword): async def set_first_password(req: FirstPassword):
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 == req.token)
.where(TokenDB.used == False)
).one_or_none()
if token_in_db:
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate token", detail="Could not validate token",
@ -223,11 +235,25 @@ async def set_first_password(req: FirstPassword):
user = get_user(username) user = get_user(username)
if user: if user:
with Session(engine) as session:
user.hashed_password = get_password_hash(req.password) user.hashed_password = get_password_hash(req.password)
session.add(user) session.add(user)
token_in_db.used = True
session.add(token_in_db)
session.commit() session.commit()
return Response("Password set successfully", status_code=status.HTTP_200_OK) 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
async def change_password( async def change_password(

View File

@ -17,13 +17,6 @@ import { apiAuth } from "./api";
// }; // };
//}; //};
// //
interface Prop {
name: string;
min: string;
max: string;
step: string;
value: string;
}
interface Params { interface Params {
nodeSize: number; nodeSize: number;
@ -36,12 +29,6 @@ interface Params {
show: number; show: number;
} }
interface DeferredProps {
timeout: number;
func: () => void;
}
let timeoutID: NodeJS.Timeout | null = null; let timeoutID: NodeJS.Timeout | null = null;
export default function Analysis() { export default function Analysis() {
const [image, setImage] = useState(""); const [image, setImage] = useState("");
@ -65,9 +52,10 @@ export default function Analysis() {
.then((data) => { .then((data) => {
setImage(data.image); setImage(data.image);
setLoading(false); setLoading(false);
}).catch((e) => {
console.log("best to just reload... ", e);
}) })
.catch((e) => {
console.log("best to just reload... ", e);
});
} }
useEffect(() => { useEffect(() => {
@ -81,19 +69,35 @@ export default function Analysis() {
function showLabel() { function showLabel() {
switch (params.show) { switch (params.show) {
case 0: return "dislike"; case 0:
case 1: return "both"; return "dislike";
case 2: return "like"; case 1:
return "both";
case 2:
return "like";
} }
} }
return ( return (
<div className="stack column dropdown"> <div className="stack column dropdown">
<button onClick={() => setShowControlPanel(!showControlPanel)}> <button onClick={() => setShowControlPanel(!showControlPanel)}>
Parameters <svg viewBox="0 0 24 24" height="1.2em" style={{ fill: "#ffffff", display: "inline", top: "0.2em", position: "relative", transform: showControlPanel ? "rotate(180deg)" : "unset" }} > <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" > </path></svg > Parameters{" "}
<svg
viewBox="0 0 24 24"
height="1.2em"
style={{
fill: "#ffffff",
display: "inline",
top: "0.2em",
position: "relative",
transform: showControlPanel ? "rotate(180deg)" : "unset",
}}
>
{" "}
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"> </path>
</svg>
</button> </button>
<div id="control-panel" className={showControlPanel ? "opened" : ""}> <div id="control-panel" className={showControlPanel ? "opened" : ""}>
<div className="control"> <div className="control">
<datalist id="markers"> <datalist id="markers">
<option value="0"></option> <option value="0"></option>
@ -109,7 +113,9 @@ export default function Analysis() {
max="2" max="2"
step="1" step="1"
width="16px" width="16px"
onChange={(evt) => setParams({ ...params, show: Number(evt.target.value) })} onChange={(evt) =>
setParams({ ...params, show: Number(evt.target.value) })
}
/> />
<label>😍</label> <label>😍</label>
</div> </div>
@ -120,7 +126,9 @@ export default function Analysis() {
<input <input
type="checkbox" type="checkbox"
checked={params.weighting} checked={params.weighting}
onChange={(evt) => setParams({ ...params, weighting: evt.target.checked })} onChange={(evt) =>
setParams({ ...params, weighting: evt.target.checked })
}
/> />
<label>weighting</label> <label>weighting</label>
</div> </div>
@ -129,7 +137,9 @@ export default function Analysis() {
<input <input
type="checkbox" type="checkbox"
checked={params.popularity} checked={params.popularity}
onChange={(evt) => setParams({ ...params, popularity: evt.target.checked })} onChange={(evt) =>
setParams({ ...params, popularity: evt.target.checked })
}
/> />
<label>popularity</label> <label>popularity</label>
</div> </div>
@ -143,9 +153,12 @@ export default function Analysis() {
max="3.001" max="3.001"
step="0.05" step="0.05"
value={params.distance} value={params.distance}
onChange={(evt) => setParams({ ...params, distance: Number(evt.target.value) })} onChange={(evt) =>
setParams({ ...params, distance: Number(evt.target.value) })
}
/> />
<span>{params.distance}</span></div> <span>{params.distance}</span>
</div>
<div className="control"> <div className="control">
<label>node size</label> <label>node size</label>
@ -154,7 +167,9 @@ export default function Analysis() {
min="500" min="500"
max="3000" max="3000"
value={params.nodeSize} value={params.nodeSize}
onChange={(evt) => setParams({ ...params, nodeSize: Number(evt.target.value) })} onChange={(evt) =>
setParams({ ...params, nodeSize: Number(evt.target.value) })
}
/> />
<span>{params.nodeSize}</span> <span>{params.nodeSize}</span>
</div> </div>
@ -166,7 +181,9 @@ export default function Analysis() {
min="4" min="4"
max="24" max="24"
value={params.fontSize} value={params.fontSize}
onChange={(evt) => setParams({ ...params, fontSize: Number(evt.target.value) })} onChange={(evt) =>
setParams({ ...params, fontSize: Number(evt.target.value) })
}
/> />
<span>{params.fontSize}</span> <span>{params.fontSize}</span>
</div> </div>
@ -179,7 +196,9 @@ export default function Analysis() {
max="5" max="5"
step="0.1" step="0.1"
value={params.edgeWidth} value={params.edgeWidth}
onChange={(evt) => setParams({ ...params, edgeWidth: Number(evt.target.value) })} onChange={(evt) =>
setParams({ ...params, edgeWidth: Number(evt.target.value) })
}
/> />
<span>{params.edgeWidth}</span> <span>{params.edgeWidth}</span>
</div> </div>
@ -191,20 +210,19 @@ export default function Analysis() {
min="10" min="10"
max="50" max="50"
value={params.arrowSize} value={params.arrowSize}
onChange={(evt) => setParams({ ...params, arrowSize: Number(evt.target.value) })} onChange={(evt) =>
setParams({ ...params, arrowSize: Number(evt.target.value) })
}
/> />
<span>{params.arrowSize}</span> <span>{params.arrowSize}</span>
</div> </div>
</div> </div>
<button onClick={() => loadImage()}>reload </button> <button onClick={() => loadImage()}>reload </button>
{ {loading ? (
loading ? (
<span className="loader"></span> <span className="loader"></span>
) : ( ) : (
<img src={"data:image/png;base64," + image} width="86%" /> <img src={"data:image/png;base64," + image} width="86%" />
) )}
} </div>
</div >
); );
} }

View File

@ -1,10 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import BarChart from "./BarChart";
import { PlayerRanking } from "./types"; import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart"; import RaceChart from "./RaceChart";
const MVPChart = () => { const MVPChart = () => {
const [data, setData] = useState({} as PlayerRanking[]); const [data, setData] = useState({} as PlayerRanking[]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -13,17 +11,26 @@ const MVPChart = () => {
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
await apiAuth("analysis/mvp", null) await apiAuth("analysis/mvp", null)
.then(json => json as Promise<PlayerRanking[]>).then(json => { setData(json.sort((a, b) => a.rank - b.rank)) }) .then((json) => json as Promise<PlayerRanking[]>)
.then((json) => {
setData(json.sort((a, b) => a.rank - b.rank));
});
setLoading(false); setLoading(false);
} }
useEffect(() => { loadData() }, []) useEffect(() => {
loadData();
}, []);
return ( return (
<> <>
{loading ? <span className="loader" /> : <RaceChart std={showStd} players={data} /> {loading ? (
} <span className="loader" />
</>) ) : (
} <RaceChart std={showStd} players={data} />
)}
</>
);
};
export default MVPChart; export default MVPChart;

View File

@ -1,6 +1,5 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types"; import { PlayerRanking } from "./types";
import { useSession } from "./Session";
interface RaceChartProps { interface RaceChartProps {
players: PlayerRanking[]; players: PlayerRanking[];

View File

@ -44,12 +44,12 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
const dialog = document.querySelector("dialog[id='ChemistryDialog']"); const dialog = document.querySelector("dialog[id='ChemistryDialog']");
(dialog as HTMLDialogElement).showModal(); (dialog as HTMLDialogElement).showModal();
setDialog("sending..."); setDialog("sending...");
let left = playersLeft.map(({ display_name }) => display_name); let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.map(({ display_name }) => display_name); let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ display_name }) => display_name); let right = playersRight.map(({ id }) => id);
const data = { user: user, hate: left, undecided: middle, love: right }; const data = { user: user.id, hate: left, undecided: middle, love: right };
const response = await apiAuth("chemistry", data); const response = await apiAuth("chemistry", data, "POST");
response.ok ? setDialog("success!") : setDialog("try sending again"); response ? setDialog(response) : setDialog("try sending again");
} }
return ( return (
@ -124,10 +124,10 @@ export function MVP({ user, players }: PlayerInfoProps) {
const dialog = document.querySelector("dialog[id='MVPDialog']"); const dialog = document.querySelector("dialog[id='MVPDialog']");
(dialog as HTMLDialogElement).showModal(); (dialog as HTMLDialogElement).showModal();
setDialog("sending..."); setDialog("sending...");
let mvps = rankedPlayers.map(({ display_name }) => display_name); let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user, mvps: mvps }; const data = { user: user.id, mvps: mvps };
const response = await apiAuth("mvps", data); const response = await apiAuth("mvps", data, "POST");
response.ok ? setDialog("success!") : setDialog("try sending again"); response ? setDialog(response) : setDialog("try sending again");
} }
return ( return (

View File

@ -64,7 +64,6 @@ export function SessionProvider(props: SessionProviderProps) {
setErr(e); setErr(e);
} }
} }
console.log("sanity", user);
let content: ReactNode; let content: ReactNode;
if (loading || (!err && !user)) if (loading || (!err && !user))

View File

@ -1,7 +1,7 @@
import { InvalidTokenError, jwtDecode, JwtPayload } from "jwt-decode"; import { jwtDecode, JwtPayload } from "jwt-decode";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { baseUrl } from "./api"; import { baseUrl } from "./api";
import { redirect, useNavigate } from "react-router"; import { useNavigate } from "react-router";
interface SetPassToken extends JwtPayload { interface SetPassToken extends JwtPayload {
name: string; name: string;
@ -63,9 +63,9 @@ export const SetPassword = () => {
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) { if (resp.status === 401) {
resp.statusText const { detail } = await resp.json();
? setError(resp.statusText) if (detail) setError(detail);
: setError("unauthorized"); else setError("unauthorized");
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
} }

View File

@ -3,10 +3,13 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@ -14,7 +17,6 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": false,
@ -22,5 +24,7 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": [
"src"
]
} }