join (or register) with invite link

This commit is contained in:
2026-01-03 17:02:44 +01:00
parent aff3b9f7be
commit bb41513571
7 changed files with 107 additions and 46 deletions

View File

@@ -13,6 +13,7 @@ from cutt.analysis import analysis_router
from cutt.mail import send_forgotten_password_link
from cutt.security import (
get_current_active_user,
join_team_token,
login_for_access_token,
logout,
register,
@@ -59,6 +60,9 @@ team_router = APIRouter(
)
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(
"/join_link/{team_id}", endpoint=join_team_token, methods=["GET"]
)
wrong_user_id_exception = HTTPException(

View File

@@ -10,6 +10,7 @@ from cutt.security import (
change_password,
get_current_active_user,
read_player_me,
verify_one_time_token,
verify_team_scope,
)
from cutt.demo import demo_players
@@ -197,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()
team = session.exec(select(Team).where(Team.id == team_id)).one()
if player and team:
team.players.append(player)
session.add(team)
session.commit()
return PlainTextResponse(
f"added {player.display_name} ({player.username}) to {team.name}"
if player in team.players:
return PlainTextResponse(
f"{player.display_name} ({player.username}) is already part of {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]):
with Session(engine) as session:
for player in players:
@@ -218,7 +251,7 @@ async def list_all_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:
return [
@@ -313,6 +346,11 @@ player_router.add_api_route(
endpoint=remove_player_from_team,
methods=["DELETE"],
)
player_router.add_api_route(
"/{team_id}/join",
endpoint=join_team,
methods=["POST"],
)
player_router.add_api_route(
"/{team_id}/list",
endpoint=list_players,

View File

@@ -225,16 +225,19 @@ def set_password_token(user: Player):
return token
def register_token(team_id: int):
def join_team_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},
data={"sub": "join", "team_id": team_id, "name": team.name},
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):
@@ -344,7 +347,7 @@ class RegisterRequest(BaseModel):
async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "register":
if action != "join":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",

View File

@@ -104,7 +104,7 @@ function TypeDnD({ user, teams, players }: PlayerInfoProps) {
useEffect(() => {
handleGet();
}, [players]);
}, [players, teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
@@ -245,7 +245,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed);
handleGet();
}, [players]);
}, [players, teams]);
useEffect(() => {
handleGet();
@@ -364,7 +364,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
useEffect(() => {
handleGet();
}, [players]);
}, [players, teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);

View File

@@ -1,14 +1,17 @@
import { jwtDecode } from "jwt-decode";
import { PassToken } from "./api";
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 { user } = useSession();
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);
@@ -17,7 +20,7 @@ export const Join = () => {
setToken(token);
try {
const payload = jwtDecode<PassToken>(token);
if (payload.sub === "register") {
if (payload.sub === "join") {
if (payload.team_id) setTeamID(payload.team_id);
} else {
setError("not a valid token for joining");
@@ -29,15 +32,32 @@ export const Join = () => {
} 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">
<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-primary is-large is-outlined">
join
<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>
);

View File

@@ -1,8 +1,7 @@
import { jwtDecode } from "jwt-decode";
import { FormEvent, useEffect, useState } from "react";
import { baseUrl, Gender, PassToken } from "./api";
import { Link, useLocation, useNavigate } from "react-router";
import { TriangleAlert } from "lucide-react";
import { useLocation, useNavigate } from "react-router";
export const Register = () => {
const [name, setName] = useState("");
@@ -28,7 +27,7 @@ export const Register = () => {
setToken(token);
try {
const payload = jwtDecode<PassToken>(token);
if (payload.sub === "register") {
if (payload.sub === "join") {
if (payload.team_id) setTeamID(payload.team_id);
} else {
setError("not a valid token for registration");
@@ -107,17 +106,6 @@ export const Register = () => {
<h1 className="title">
Register {teamName && `in team "${teamName}"`}
</h1>
<div className="notification is-warning">
<div className="icon-text">
<span className="icon">
<TriangleAlert />
</span>
<span>
If you already have an account,{" "}
<a href={`/join${location.search}&login=1`}>login</a> first.
</span>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="field">
<div className="field">

View File

@@ -7,15 +7,10 @@ import {
} from "react";
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login";
import Header from "./Header";
import { Team } from "./types";
import Loading from "./Loading";
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router";
import { Link, useLocation } from "react-router";
import { TriangleAlert } from "lucide-react";
export interface SessionProviderProps {
children: ReactNode;
@@ -53,8 +48,6 @@ export function SessionProvider(props: SessionProviderProps) {
const [error, setError] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
const location = useLocation();
let [searchParams] = useSearchParams();
const navigate = useNavigate();
function loadUser() {
setLoading(true);
@@ -71,8 +64,12 @@ export function SessionProvider(props: SessionProviderProps) {
}
async function loadTeam() {
const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
const loaded_teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (loaded_teams)
setTeams({
teams: loaded_teams,
activeTeam: teams?.activeTeam || loaded_teams[0].id,
});
}
async function reloadPlayers() {
@@ -110,8 +107,6 @@ export function SessionProvider(props: SessionProviderProps) {
if (loading || (!error && !user))
content = <section className="section is-medium">{Loading}</section>;
else if (error) {
if (location.pathname === "/join" && !searchParams.get("login"))
navigate(`/register${location.search}`);
content = (
<section className="section is-medium">
<div className="container is-max-tablet">
@@ -125,6 +120,19 @@ export function SessionProvider(props: SessionProviderProps) {
/>
</p>
</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} />
</div>
</section>