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

View File

@@ -10,6 +10,7 @@ from cutt.security import (
change_password, change_password,
get_current_active_user, get_current_active_user,
read_player_me, read_player_me,
verify_one_time_token,
verify_team_scope, verify_team_scope,
) )
from cutt.demo import demo_players from cutt.demo import demo_players
@@ -197,12 +198,44 @@ def add_player_to_team(player_id: int, team_id: int):
player = session.exec(select(P).where(P.id == player_id)).one() player = session.exec(select(P).where(P.id == player_id)).one()
team = session.exec(select(Team).where(Team.id == team_id)).one() team = session.exec(select(Team).where(Team.id == team_id)).one()
if player and team: if player and team:
if player in team.players:
return PlainTextResponse(
f"{player.display_name} ({player.username}) is already part of {team.name}"
)
else:
team.players.append(player) team.players.append(player)
session.add(team) session.add(team)
session.commit() session.commit()
return PlainTextResponse( return PlainTextResponse(
f"added {player.display_name} ({player.username}) to {team.name}" f"added {player.display_name} ({player.username}) to {team.name}"
) )
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="something went wrong",
)
class JoinRequest(BaseModel):
token: str
team_id: int
def join_team(r: JoinRequest, user: Annotated[P, Depends(get_current_active_user)]):
payload = verify_one_time_token(r.token)
action: str = payload.get("sub")
if action != "join":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
team_id: int = payload.get("team_id")
if team_id != r.team_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong team",
)
return add_player_to_team(user.id, r.team_id)
def add_players(players: list[P]): def add_players(players: list[P]):
@@ -218,7 +251,7 @@ async def list_all_players():
async def list_players( async def list_players(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)] team_id: int, user: Annotated[P, Depends(get_current_active_user)]
): ):
if team_id == 42: if team_id == 42:
return [ return [
@@ -313,6 +346,11 @@ player_router.add_api_route(
endpoint=remove_player_from_team, endpoint=remove_player_from_team,
methods=["DELETE"], methods=["DELETE"],
) )
player_router.add_api_route(
"/{team_id}/join",
endpoint=join_team,
methods=["POST"],
)
player_router.add_api_route( player_router.add_api_route(
"/{team_id}/list", "/{team_id}/list",
endpoint=list_players, endpoint=list_players,

View File

@@ -225,16 +225,19 @@ def set_password_token(user: Player):
return token return token
def register_token(team_id: int): def join_team_token(team_id: int):
with Session(engine) as session: with Session(engine) as session:
team = session.exec(select(Team).where(Team.id == team_id)).one() team = session.exec(select(Team).where(Team.id == team_id)).one()
if team: if team:
expire = timedelta(days=30) expire = timedelta(days=30)
token = create_access_token( token = create_access_token(
data={"sub": "register", "team_id": team_id, "name": team.name}, data={"sub": "join", "team_id": team_id, "name": team.name},
expires_delta=expire, expires_delta=expire,
) )
return token if token:
session.add(TokenDB(token=token))
session.commit()
return PlainTextResponse(f"https://cutt.0124816.xyz/join?token={token}")
def verify_one_time_token(token: str): def verify_one_time_token(token: str):
@@ -344,7 +347,7 @@ class RegisterRequest(BaseModel):
async def register(req: RegisterRequest): async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token) payload = verify_one_time_token(req.token)
action: str = payload.get("sub") action: str = payload.get("sub")
if action != "register": if action != "join":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.", detail="wrong type of token.",

View File

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

View File

@@ -1,14 +1,17 @@
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { PassToken } from "./api"; import { apiAuth, PassToken } from "./api";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { UsersIcon } from "lucide-react";
import { useNavigate } from "react-router";
import { useSession } from "./Session"; import { useSession } from "./Session";
export const Join = () => { export const Join = () => {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const { user } = useSession();
const [teamName, setTeamName] = useState(""); const [teamName, setTeamName] = useState("");
const [teamID, setTeamID] = useState<number>(); const [teamID, setTeamID] = useState<number>();
const [error, setError] = useState(""); const [error, setError] = useState("");
const { teams, setTeams } = useSession();
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -17,7 +20,7 @@ export const Join = () => {
setToken(token); setToken(token);
try { try {
const payload = jwtDecode<PassToken>(token); const payload = jwtDecode<PassToken>(token);
if (payload.sub === "register") { if (payload.sub === "join") {
if (payload.team_id) setTeamID(payload.team_id); if (payload.team_id) setTeamID(payload.team_id);
} else { } else {
setError("not a valid token for joining"); setError("not a valid token for joining");
@@ -29,15 +32,32 @@ export const Join = () => {
} else setError("no token found"); } 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 ( return (
<section className="section is-medium"> <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> <h1 className="title">Join "{teamName}"</h1>
<div className="field is-grouped is-grouped-centered"> <div className="field is-grouped is-grouped-centered">
<button className="button is-primary is-large is-outlined"> <button className="button is-dark is-large" onClick={handleJoin}>
join <span className="icon">
<UsersIcon />
</span>
<span> join team</span>
</button> </button>
</div> </div>
<span className="help is-danger">{error}</span>
</div> </div>
</section> </section>
); );

View File

@@ -1,8 +1,7 @@
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { baseUrl, Gender, PassToken } from "./api"; import { baseUrl, Gender, PassToken } from "./api";
import { Link, useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { TriangleAlert } from "lucide-react";
export const Register = () => { export const Register = () => {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -28,7 +27,7 @@ export const Register = () => {
setToken(token); setToken(token);
try { try {
const payload = jwtDecode<PassToken>(token); const payload = jwtDecode<PassToken>(token);
if (payload.sub === "register") { if (payload.sub === "join") {
if (payload.team_id) setTeamID(payload.team_id); if (payload.team_id) setTeamID(payload.team_id);
} else { } else {
setError("not a valid token for registration"); setError("not a valid token for registration");
@@ -107,17 +106,6 @@ export const Register = () => {
<h1 className="title"> <h1 className="title">
Register {teamName && `in team "${teamName}"`} Register {teamName && `in team "${teamName}"`}
</h1> </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}> <form onSubmit={handleSubmit}>
<div className="field"> <div className="field">
<div className="field"> <div className="field">

View File

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