3 Commits

Author SHA1 Message Date
46fd498c32 easier disabling players 2025-12-23 08:54:02 +01:00
4022136970 forgotten password routine 2025-12-23 08:53:46 +01:00
d0140f4cfb implement reset password email 2025-12-22 11:51:54 +01:00
9 changed files with 283 additions and 20 deletions

View File

@@ -0,0 +1,111 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>cutt - set password</title>
</head>
<body
style="
margin: auto;
max-width: 800px;
padding: 2rem 1.5rem;
background-color: white;
"
>
<p
style="
display: flex;
justify-content: center;
text-align: center;
padding: 2rem;
"
>
<svg xmlns="http://www.w3.org/2000/svg" width="200" viewBox="0 0 80 50">
<ellipse
cx="40"
cy="25"
rx="20.263"
ry="20.633"
style="
fill: #c7d6f1;
fill-opacity: 1;
stroke: #36c;
stroke-width: 8.73336;
"
/>
<path
d="M0 17.67h80v14.66H0Z"
style="
fill: #000;
stroke-width: 3.56018;
paint-order: stroke fill markers;
"
/>
<text
x="39.788"
y="29.819"
style="
font-style: normal;
font-variant: normal;
font-weight: 700;
font-stretch: normal;
font-size: 14px;
line-height: 1;
font-family: Sans;
-inkscape-font-specification: &quot;Sans Bold&quot;;
text-align: center;
letter-spacing: 2.83px;
writing-mode: lr-tb;
direction: ltr;
text-anchor: middle;
fill: #fff;
stroke: #fff;
stroke-width: 0.655;
stroke-dasharray: none;
stroke-opacity: 1;
paint-order: stroke fill markers;
"
>
<tspan
x="39.788"
y="29.819"
style="
font-style: normal;
font-variant: normal;
font-weight: 700;
font-stretch: normal;
font-size: 14px;
font-family: Sans;
-inkscape-font-specification: &quot;Sans Bold&quot;;
letter-spacing: 2.83px;
fill: #fff;
stroke: #fff;
stroke-width: 0.655;
stroke-dasharray: none;
stroke-opacity: 1;
"
>
CUTT
</tspan>
</text>
</svg>
</p>
<p>Hello USER,</p>
<p>click on the following link to set yourself a new password.</p>
<p style="text-align: center; padding: 2rem">
<a
href="LINK"
style="
color: ghostwhite;
font-weight: bold;
background-color: #36c;
border-radius: 0.25rem;
padding: 1rem;
text-decoration: none;
"
>reset password</a
>
</p>
<p>Cheers,<br />Julius</p>
</body>
</html>

66
cutt/mail.py Normal file
View File

@@ -0,0 +1,66 @@
from email.message import EmailMessage
from email.utils import formataddr
from random import random
import smtplib
import os
import ssl
from time import sleep
from dotenv import load_dotenv
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from cutt.db import Player, TokenDB, engine
from cutt.security import set_password_token
P = Player
load_dotenv()
def generate_password_link(user: Player):
with Session(engine) as session:
token = set_password_token(user)
if token:
session.add(TokenDB(token=token))
session.commit()
return f"https://cutt.0124816.xyz/setpassword?token={token}"
class EmailRequest(BaseModel):
email: str
def send_forgotten_password_link(email: EmailRequest):
with Session(engine) as session:
user = session.exec(
select(P).where(P.email == email.email, P.disabled != True)
).one_or_none()
if user and user.email:
link = generate_password_link(user)
msg = EmailMessage()
msg["Subject"] = "CUTT - reset password"
msg["From"] = "CUTT - cool ultimate team tool <cutt@0124816.xyz>"
msg["To"] = formataddr((user.display_name, user.email))
msg.set_content(
f"Hello {user.display_name},\nclick on the following link to set yourself a new password.\n\n{link}\n\nCheers,\nJulius"
)
with open("cutt/forgotten_password.html") as f:
html_body = (
f.read().replace("USER", user.display_name).replace("LINK", link)
)
msg.add_alternative(html_body, subtype="html")
context = ssl.create_default_context()
with smtplib.SMTP(
host=os.environ["SMTP_HOST"],
port=int(os.environ["SMTP_PORT"]),
timeout=20,
) as server:
server.starttls(context=context)
server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
server.send_message(msg)
else:
sleep(random())
return PlainTextResponse(
"a link will be sent to this email, if it belongs to an existing user."
)

View File

@@ -10,6 +10,7 @@ from sqlmodel import (
)
from fastapi.middleware.cors import CORSMiddleware
from cutt.analysis import analysis_router
from cutt.mail import send_forgotten_password_link
from cutt.security import (
get_current_active_user,
login_for_access_token,
@@ -229,6 +230,9 @@ api_router.include_router(
api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)])
api_router.include_router(analysis_router)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route(
"/forgotten_password", endpoint=send_forgotten_password_link, 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"])

View File

@@ -161,9 +161,9 @@ def disable_player_team(
)
def disable_player(r: DisablePlayerRequest):
def disable_player(player_id: int):
with Session(engine) as session:
player = session.exec(select(P).where(P.id == r.player_id)).one_or_none()
player = session.exec(select(P).where(P.id == player_id)).one_or_none()
if player:
player.disabled = True
session.add(player)
@@ -308,7 +308,7 @@ player_router.add_api_route(
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
player_router.add_api_route(
"/disable",
"/disable/{player_id}",
endpoint=disable_player,
methods=["DELETE"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],

View File

@@ -212,14 +212,12 @@ async def logout(response: Response):
return {"message": "Successfully logged out"}
def set_password_token(username: str):
user = get_user(username)
if user:
def set_password_token(user: Player):
expire = timedelta(days=30)
token = create_access_token(
data={
"sub": "set password",
"username": username,
"username": user.username,
"name": user.display_name,
},
expires_delta=expire,

View File

@@ -7,7 +7,7 @@ from cutt.db import Player, TokenDB, engine
if len(sys.argv) > 1:
with Session(engine) as session:
for p in session.exec(
select(Player.username).where(Player.username == sys.argv[1].strip())
select(Player).where(Player.username == sys.argv[1].strip())
):
print(p)
token = set_password_token(p)

View File

@@ -9,6 +9,7 @@ import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword";
import { Register } from "./Register";
import { ForgotPassword } from "./Login";
const Maintenance = () => {
return (
@@ -28,6 +29,7 @@ function App() {
<Routes>
<Route path="/register" element={<Register />} />
<Route path="/setpassword" element={<SetPassword />} />
<Route path="/forgotten_password" element={<ForgotPassword />} />
<Route
path="/*"
element={

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { currentUser, login, User } from "./api";
import { FormEvent, useEffect, useState } from "react";
import { baseUrl, currentUser, login, User } from "./api";
import Header from "./Header";
import { useLocation, useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
@@ -35,7 +35,7 @@ export const Login = ({ onLogin }: LoginProps) => {
onLogin(user);
}
function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: FormEvent) {
e.preventDefault();
doLogin();
}
@@ -86,6 +86,9 @@ export const Login = ({ onLogin }: LoginProps) => {
}}
/>
</p>
<p className="help is-link has-text-right">
<a href="forgotten_password">forgot password?</a>
</p>
{error && <p className="help is-danger">{error}</p>}
</div>
<div className="control">
@@ -102,3 +105,81 @@ export const Login = ({ onLogin }: LoginProps) => {
</form>
);
};
export const ForgotPassword = () => {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const req = new Request(`${baseUrl}api/forgotten_password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: decodeURI(email) }),
});
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
if (resp.ok) {
const content = await resp.text();
if (content) setError(content);
}
if (!resp.ok) {
const content = await resp.text();
if (content) setError(content);
else setError("unauthorized");
throw new Error("Unauthorized");
}
}
return (
<section className="section is-medium">
<div className="container is-max-tablet">
<div className="block">
<p className="level-item has-text-centered">
<img
className="image"
alt="cool ultimate team tool"
src="cutt.svg"
style={{ width: 200 }}
/>
</p>
</div>
<h1 className="title">Forgot password</h1>
<form onSubmit={handleSubmit}>
<div className="field">
<div className="field">
<label className="label">email</label>
<div className="control">
<input
className="input"
required
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError("");
}}
/>
</div>
</div>
</div>
<div className="field">
<p className={"help" + (error ? " is-danger" : " is-success")}>
{error}
</p>
</div>
<div className="field is-grouped is-grouped-centered">
<button className="button is-light is-primary">send email</button>
</div>
</form>
</div>
</section>
);
};

View File

@@ -21,4 +21,5 @@ dependencies = [
[dependency-groups]
dev = [
"pyqt6>=6.10.1",
"python-dotenv>=1.2.1",
]