Compare commits

..

No commits in common. "43f9b0d47cec867596f20606cb0f6bc3691b011b" and "d3daa83d68228557ad7db43e9a52a21af2f98620" have entirely different histories.

11 changed files with 180 additions and 446 deletions

View File

@ -70,10 +70,7 @@ def graph_json(
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no players found in your team",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
for p in players:
player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name})

View File

@ -1,6 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from cutt.db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
@ -14,7 +14,6 @@ from cutt.security import (
get_current_active_user,
login_for_access_token,
logout,
register,
set_first_password,
)
from cutt.player import player_router
@ -178,20 +177,6 @@ api_router.include_router(team_router, dependencies=[Depends(get_current_active_
api_router.include_router(analysis_router)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/register", endpoint=register, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])
app.include_router(api_router)
# app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
@app.get("/")
async def root():
return FileResponse("dist/index.html")
@app.exception_handler(404)
async def exception_404_handler(request, exc):
return FileResponse("dist/index.html")
app.mount("/", StaticFiles(directory="dist"), name="ui")
app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")

View File

@ -145,22 +145,8 @@ async def list_all_players():
return session.exec(select(P)).all()
async def list_players(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
async def list_players(team_id: int):
with Session(engine) as session:
current_user = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
).one_or_none()
if not current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="you're not in this team",
)
players = session.exec(
select(P)
.join(PlayerTeamLink)
@ -201,6 +187,7 @@ player_router.add_api_route(
"/{team_id}/list",
endpoint=list_players,
methods=["GET"],
dependencies=[Depends(get_current_active_user)],
)
player_router.add_api_route(
"/list",

View File

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

View File

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

View File

@ -148,10 +148,11 @@ export default function Avatar() {
return (
<>
<div className="avatars" style={{ display: user ? "block" : "none" }}>
<div className="avatars">
<div
className="avatar"
onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();

View File

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

View File

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

View File

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

View File

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

View File

@ -2,15 +2,9 @@ import { FormEvent, useEffect, useState } from "react";
import { apiAuth, loadPlayers, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
const TeamPanel = () => {
const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
navigate("/", { replace: true });
}, [user]);
export const TeamPanel = () => {
const { teams } = useSession();
const newPlayerTemplate = {
id: 0,
username: "",
@ -205,4 +199,3 @@ const TeamPanel = () => {
);
} else <span className="loader" />;
};
export default TeamPanel;