Compare commits

...
14 Commits
Author SHA1 Message Date
ben cfbd561763 lol 2026-08-20 17:19:22 +02:00
ben fcb6690af5 lol 2026-08-20 16:59:27 +02:00
Ben de Roo 08e02dd556 ja 2026-08-20 16:51:58 +02:00
ben a980812137 msg 2026-08-20 12:23:30 +02:00
ben e61750f314 kaas is toch zo lekker 2026-08-20 09:06:47 +02:00
ben af1c147fd0 kaas is toch zo lekker 2026-08-20 09:04:41 +02:00
ben 792ce850b6 ik hou van kaas 2026-08-19 14:17:30 +02:00
ben fae25adb99 ik hou van kaas 2026-08-19 12:18:53 +02:00
ben 8fae1ffb3d commit 2026-08-19 12:10:02 +02:00
ben bfab69cbb4 ik hou van kaas 2026-08-19 11:58:37 +02:00
ben 468c34ff92 ik hou van kaas 2026-08-19 11:56:44 +02:00
ben 8fe812e8e6 fixed bugs 2026-08-19 09:38:31 +02:00
ben bf601846db new frontend
ik ben zo cool
2026-08-19 09:33:48 +02:00
ben 1822dea7e3 Clean up gitignore, stop tracking build artifacts 2026-08-19 09:32:32 +02:00
17 changed files with 959 additions and 311 deletions
+27 -6
View File
@@ -1,6 +1,27 @@
.env
__pycache__/
*.py[cod]
database/calendar.db
database/calendar.db-journal
.vscode
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -102,8 +102,9 @@ def get_current_user(
algorithms=[ALGORITHM]
)
return payload["user_id"]
print("DEBUG JWT payload:", payload)
return payload["user_id"]
def get_user(user_id):
return fetch_one(
+216 -125
View File
@@ -1,17 +1,19 @@
import hashlib
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import os
import secrets
import smtplib
import sqlite3
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
DB_NAME = Path(__file__).parent.parent / "database" / "calendar.db"
@@ -27,8 +29,198 @@ def hash_token(token: str) -> str:
token.encode("utf-8")
).hexdigest()
def send_email(
email: str,
subject: str,
plain_text: str,
html: str,
):
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_username = os.getenv("SMTP_USERNAME")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM")
def update_email(user_id: int, new_email: str):
if not all([
smtp_host,
smtp_username,
smtp_password,
smtp_from,
]):
raise RuntimeError("SMTP configuration is incomplete")
message = EmailMessage()
message["From"] = smtp_from
message["To"] = email
message["Subject"] = subject
# Plain-text fallback
message.set_content(plain_text)
# HTML version
message.add_alternative(
html,
subtype="html",
)
print(f"Sending email to {email}")
with smtplib.SMTP(
smtp_host,
smtp_port,
) as smtp:
print("SMTP connected")
smtp.starttls()
print("TLS started")
smtp.login(
smtp_username,
smtp_password,
)
print("SMTP login successful")
smtp.send_message(message)
print("Email sent")
def send_verification_email(
email: str,
token: str,
):
verification_url = (
"https://ben.de-roo.org"
f"/api/verify-email?token={token}"
)
plain_text = f"""Hello,
You requested to use this email address for your Calendar account.
Verify your email address using this link:
{verification_url}
This link expires after 24 hours.
If you did not request this, you can ignore this email.
"""
html = f"""\
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<p>Hello,</p>
<p>
You requested to use this email address
for your Calendar account.
</p>
<p>
<a href="{verification_url}">
https://ben.de-roo.org/api/verify
</a>
</p>
<p>
This link expires after 24 hours.
</p>
<p>
If you did not request this,
you can ignore this email.
</p>
</body>
</html>
"""
send_email(
email=email,
subject="Verify your Calendar email address",
plain_text=plain_text,
html=html,
)
def send_password_reset_email(
email: str,
token: str,
):
password_reset_url = (
"https://ben.de-roo.org"
f"/api/update-password_page?token={token}"
)
plain_text = f"""Hello,
You requested to reset your Calendar password.
Reset your password using this link:
{password_reset_url}
This link expires after 1 hour.
If you did not request this, you can ignore this email.
"""
html = f"""\
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<p>Hello,</p>
<p>
You requested to reset your Calendar password.
</p>
<p>
<a href="{password_reset_url}">
https://ben.de-roo.org/api/update-password_page
</a>
</p>
<p>
This link expires after 1 hour.
</p>
<p>
If you did not request this,
you can ignore this email.
</p>
</body>
</html>
"""
send_email(
email=email,
subject="Reset your password",
plain_text=plain_text,
html=html,
)
def update_email(
user_id: int,
new_email: str,
):
new_email = new_email.strip().lower()
conn = get_connection()
@@ -57,7 +249,10 @@ def update_email(user_id: int, new_email: str):
WHERE email = ?
AND id != ?
""",
(new_email, user_id),
(
new_email,
user_id,
),
)
if cur.fetchone() is not None:
@@ -73,7 +268,10 @@ def update_email(user_id: int, new_email: str):
email_verified = 0
WHERE id = ?
""",
(new_email, user_id),
(
new_email,
user_id,
),
)
cur.execute(
@@ -104,7 +302,9 @@ def update_email(user_id: int, new_email: str):
conn.close()
def create_verification(user_id: int):
def create_verification(
user_id: int,
):
token = secrets.token_urlsafe(32)
token_hash = hash_token(token)
@@ -149,7 +349,9 @@ def create_verification(user_id: int):
conn.close()
def verify_email(token: str):
def verify_email(
token: str,
):
token_hash = hash_token(token)
conn = get_connection()
@@ -181,6 +383,7 @@ def verify_email(token: str):
)
if datetime.now(timezone.utc) >= expires_at:
cur.execute(
"""
DELETE FROM email_verifications
@@ -222,121 +425,9 @@ def verify_email(token: str):
finally:
conn.close()
def send_verification_email(email: str, token: str):
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_username = os.getenv("SMTP_USERNAME")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM")
if not all([
smtp_host,
smtp_username,
smtp_password,
smtp_from,
]):
raise RuntimeError("SMTP configuration is incomplete")
verification_url = (
f"https://ben.de-roo.org/api/verify-email?token={token}"
)
message = EmailMessage()
message["From"] = smtp_from
message["To"] = email
message["Subject"] = "Verify your Calendar email address"
message.set_content(
f"""Hello,
You requested to use this email address for your Calendar account.
Verify your email address using this link:
{verification_url}
This link expires after 24 hours.
If you did not request this, you can ignore this email.
"""
)
print(f"Sending verification email to {email}")
with smtplib.SMTP(smtp_host, smtp_port) as smtp:
print("SMTP connected")
smtp.starttls()
print("TLS started")
smtp.login(
smtp_username,
smtp_password,
)
print("SMTP login successful")
smtp.send_message(message)
print("Verification email sent")
def send_password_reset_email(email: str, token: str):
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_username = os.getenv("SMTP_USERNAME")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM")
if not all([
smtp_host,
smtp_username,
smtp_password,
smtp_from,
]):
raise RuntimeError("SMTP configuration is incomplete")
password_reset_url = (
f"https://ben.de-roo.org/api/update-password_page?token={token}"
)
message = EmailMessage()
message["From"] = smtp_from
message["To"] = email
message["Subject"] = "Reset your password"
message.set_content(
f"""Hello,
You requested to use this email address for reseting your Calendar password.
Reset your password using this link:
{password_reset_url}
This link expires after 24 hours.
If you did not request this, you can ignore this email.
"""
)
print(f"Sending password reset email to {email}")
with smtplib.SMTP(smtp_host, smtp_port) as smtp:
print("SMTP connected")
smtp.starttls()
print("TLS started")
smtp.login(
smtp_username,
smtp_password,
)
print("SMTP login successful")
smtp.send_message(message)
print("Password reset email sent")
def create_password_reset(user_id: int):
def create_password_reset(
user_id: int,
):
token = secrets.token_urlsafe(32)
token_hash = hash_token(token)
+1 -1
View File
@@ -1,4 +1,4 @@
from database import execute, fetch_one
from database import execute, fetch_one, fetch_all
def respond_to_event(event_id, user_id, status):
+705 -107
View File
@@ -339,8 +339,13 @@ def api_update_member_role(
data: MemberRoleUpdate,
current_user: int = Depends(get_current_user),
):
current_role = get_role(room_id, current_user)
target_role = get_role(room_id, user_id)
role_levels = {
"member": 1,
"admin": 2,
"owner": 3,
}
current_role = get_role(room_id, current_user) or 0
target_role = get_role(room_id, user_id) or 0
if current_role is None:
raise HTTPException(
@@ -1077,57 +1082,333 @@ def api_update_email(
@app.get(
"/verify-email",
response_class=HTMLResponse,
summary="Verify email address",
)
def api_verify_email(token: str = Query(...)):
def api_verify_email(
token: str = Query(...),
):
result = verify_email(token)
if result["status"] == "failed":
if result["reason"] == "token_expired":
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Verification expired</title>
</head>
<body>
<h1>Verification expired</h1>
<p>This verification link has expired.</p>
</body>
</html>
"""
return HTMLResponse(
content="""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Verification failed</title>
</head>
<body>
<h1>Verification failed</h1>
<p>This verification link is invalid.</p>
</body>
</html>
"""
<title>Verification expired</title>
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email verified</title>
</head>
<body>
<div class="box">
<h1>Email verified</h1>
<p>Your email address has been successfully verified.</p>
<p>You may now close this tab.</p>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
.card {
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #2b2115;
color: #f0a83b;
font-size: 30px;
}
h1 {
margin: 0 0 12px;
font-size: 26px;
}
p {
margin: 0;
color: #9da4ae;
line-height: 1.6;
}
.footer {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #272c33;
color: #6f7782;
font-size: 13px;
}
</style>
</head>
<body>
<main class="card">
<div class="icon">!</div>
<h1>Verification expired</h1>
<p>
This email verification link has expired.
Please request a new verification email.
</p>
<div class="footer">
Calendar API
</div>
</body>
</html>
"""
</main>
</body>
</html>
""",
status_code=410,
)
return HTMLResponse(
content="""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verification failed</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
.card {
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #2a1719;
color: #ef6461;
font-size: 30px;
}
h1 {
margin: 0 0 12px;
font-size: 26px;
}
p {
margin: 0;
color: #9da4ae;
line-height: 1.6;
}
.footer {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #272c33;
color: #6f7782;
font-size: 13px;
}
</style>
</head>
<body>
<main class="card">
<div class="icon">×</div>
<h1>Verification failed</h1>
<p>
This verification link is invalid or has already been used.
</p>
<div class="footer">
Calendar API
</div>
</main>
</body>
</html>
""",
status_code=400,
)
return HTMLResponse(
content="""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email verified</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
.card {
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #14271d;
color: #4ade80;
font-size: 30px;
}
h1 {
margin: 0 0 12px;
font-size: 26px;
}
p {
margin: 8px 0 0;
color: #9da4ae;
line-height: 1.6;
}
.footer {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #272c33;
color: #6f7782;
font-size: 13px;
}
</style>
</head>
<body>
<main class="card">
<div class="icon">✓</div>
<h1>Email verified</h1>
<p>
Your email address has been successfully verified.
</p>
<p>
You may now close this tab.
</p>
<div class="footer">
Calendar API
</div>
</main>
</body>
</html>
""",
)
@app.post(
"/forgot-password",
@@ -1142,47 +1423,194 @@ def api_forgot_password(
@app.get(
"/update-password_page",
response_class=HTMLResponse,
summary="Password reset page",
)
def update_password_page(token: str = Query(...)):
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset password</title>
</head>
<body>
def update_password_page(
token: str = Query(...),
):
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset password</title>
<style>
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}}
.card {{
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}}
h1 {{
margin: 0 0 10px;
font-size: 28px;
}}
.description {{
margin: 0 0 30px;
color: #9da4ae;
line-height: 1.6;
}}
label {{
display: block;
margin-bottom: 8px;
color: #c7ccd3;
font-size: 14px;
font-weight: 600;
}}
input {{
width: 100%;
padding: 13px 14px;
border: 1px solid #343a43;
border-radius: 8px;
background: #0f1216;
color: #e8eaed;
font-size: 15px;
outline: none;
}}
input:focus {{
border-color: #6b7280;
}}
button {{
width: 100%;
margin-top: 22px;
padding: 13px 16px;
border: 0;
border-radius: 8px;
background: #e8eaed;
color: #111318;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}}
button:hover {{
background: #ffffff;
}}
.requirements {{
margin-top: 10px;
color: #6f7782;
font-size: 13px;
}}
.footer {{
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #272c33;
color: #6f7782;
font-size: 13px;
text-align: center;
}}
</style>
</head>
<body>
<main class="card">
<h1>Reset password</h1>
<form method="post" action="/api/update-password">
<p class="description">
Enter a new password for your account.
</p>
<form
method="post"
action="/update-password"
>
<input
type="hidden"
name="token"
value="{token}"
>
<label>
New password:
<input
type="password"
name="new_password"
minlength="8"
required
>
<label for="new_password">
New password
</label>
<input
id="new_password"
type="password"
name="new_password"
minlength="8"
autocomplete="new-password"
required
>
<div class="requirements">
Minimum 8 characters.
</div>
<button type="submit">
Reset password
Update password
</button>
</form>
</body>
</html>
"""
<div class="footer">
Calendar API
</div>
</main>
</body>
</html>
""",
)
@app.post(
"/update-password",
response_class=HTMLResponse,
summary="Reset password",
)
def update_password(
token: str = Form(...),
@@ -1194,49 +1622,219 @@ def update_password(
)
if result["status"] == "failed":
if result["reason"] == "token_expired":
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Reset expired</title>
</head>
<body>
<h1>Reset link expired</h1>
<p>This password reset link has expired.</p>
</body>
</html>
"""
return HTMLResponse(
content="""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Reset failed</title>
</head>
<body>
<h1>Reset failed</h1>
<p>This password reset link is invalid.</p>
</body>
</html>
"""
<title>Reset link expired</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family: system-ui, sans-serif;
}
.card {
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,.45);
}
h1 {
margin-bottom: 12px;
}
p {
color: #9da4ae;
line-height: 1.6;
}
</style>
</head>
<body>
<main class="card">
<h1>Reset link expired</h1>
<p>
This password reset link has expired.
Please request a new password reset.
</p>
</main>
</body>
</html>
""",
status_code=410,
)
return HTMLResponse(
content="""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset failed</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family: system-ui, sans-serif;
}
.card {
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,.45);
}
h1 {
margin-bottom: 12px;
}
p {
color: #9da4ae;
line-height: 1.6;
}
</style>
</head>
<body>
<main class="card">
<h1>Reset failed</h1>
<p>
This password reset link is invalid or has already been used.
</p>
</main>
</body>
</html>
""",
status_code=400,
)
return HTMLResponse(
content="""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password updated</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0b0d10;
color: #e8eaed;
font-family: system-ui, sans-serif;
}
.card {
width: 100%;
max-width: 460px;
padding: 40px;
background: #15181d;
border: 1px solid #2a2f36;
border-radius: 14px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,.45);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #14271d;
color: #4ade80;
font-size: 30px;
}
h1 {
margin: 0 0 12px;
}
p {
margin: 0;
color: #9da4ae;
line-height: 1.6;
}
</style>
</head>
<body>
<main class="card">
<div class="icon">✓</div>
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Password updated</title>
</head>
<body>
<h1>Password updated</h1>
<p>Your password has been successfully changed.</p>
<p>You may now close this tab.</p>
</body>
</html>
"""
<p>
Your password has been successfully changed.
</p>
<p>
You may now close this tab.
</p>
</main>
</body>
</html>
""",
)
# easter egg
+4 -6
View File
@@ -8,7 +8,7 @@ ROLE_LEVELS = {
}
def get_role(room_id: int, user_id: int) -> str | None:
def get_role(room_id: int, user_id: int) -> int | None:
member = fetch_one(
"""
SELECT role
@@ -22,8 +22,7 @@ def get_role(room_id: int, user_id: int) -> str | None:
if member is None:
return None
return member["role"]
return ROLE_LEVELS.get(member["role"])
def has_role(
room_id: int,
@@ -36,12 +35,11 @@ def has_role(
Role hierarchy:
owner > admin > member
"""
user_role = get_role(room_id, user_id)
user_level = get_role(room_id, user_id)
if user_role is None:
if user_level is None:
return False
user_level = ROLE_LEVELS.get(user_role, 0)
required_level = ROLE_LEVELS.get(required_role, 0)
if required_level == 0:
View File
-63
View File
@@ -1,63 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, sans-serif;
background-color: #3d3846;
color: #f5f5f5;
min-height: 100vh;
}
.container {
width: min(1100px, 90%);
margin: 0 auto;
}
h1 {
font-size: 3rem;
line-height: 1.1;
}
p {
color: #a9adb7;
line-height: 1.6;
}
.button {
display: inline-block;
padding: 0.8rem 1.2rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
background: #ffffff;
color: #111;
}
.button:hover {
opacity: 0.85;
}
.hero {
min-height: 100vh;
display: flex;
align-items: center;
}
.hero-content {
max-width: 650px;
}
.hero-content p {
margin-top: 1rem;
max-width: 500px;
}
.hero-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
+2
View File
@@ -4,3 +4,5 @@ python-dotenv
fastapi
pydantic
uvicorn
email-validator
python-multipart