Initial Release

This commit is contained in:
2026-02-03 08:22:21 +01:00
parent 8c4a6a1c65
commit 63162feffd
17 changed files with 615 additions and 0 deletions

11
.env Normal file
View File

@@ -0,0 +1,11 @@
POSTGRES_DB=cluedo
POSTGRES_USER=cluedo
POSTGRES_PASSWORD=supersecret
# Backend
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
BACKEND_BASE_URL=http://localhost:8080
# Admin initial user (wird beim Start angelegt, falls nicht existiert)
ADMIN_EMAIL=admin@local
ADMIN_PASSWORD=ChangeMeNow123!

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

19
backend/app/db.py Normal file
View File

@@ -0,0 +1,19 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

61
backend/app/main.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal
from .models import User, Entry, Category, Role
from .security import hash_password
from .routes.auth import router as auth_router
from .routes.admin import router as admin_router
from .routes.games import router as games_router
app = FastAPI(title="Cluedo Sheet")
# Intern: Frontend läuft auf :8081
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(games_router)
def seed_entries(db: Session):
# Du kannst hier deine HP-Edition Einträge reinschreiben.
# (Für rein private Nutzung ok öffentlich würde ichs generisch machen.)
if db.query(Entry).count() > 0:
return
suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"]
items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"]
locations = ["Große Halle","Krankenflügel","Raum der Wünsche","Klassenzimmer für Zaubertränke","Pokalszimmer","Klassenzimmer für Wahrsagen","Eulerei","Bibliothek","Verteidigung gegen die dunklen Künste"]
for s in suspects:
db.add(Entry(category=Category.suspect.value, label=s))
for i in items:
db.add(Entry(category=Category.item.value, label=i))
for l in locations:
db.add(Entry(category=Category.location.value, label=l))
db.commit()
def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first()
if not u:
db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value))
db.commit()
@app.on_event("startup")
def on_startup():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
ensure_admin(db)
seed_entries(db)
finally:
db.close()

51
backend/app/models.py Normal file
View File

@@ -0,0 +1,51 @@
import enum
import uuid
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .db import Base
class Role(str, enum.Enum):
admin = "admin"
user = "user"
class Category(str, enum.Enum):
suspect = "suspect"
item = "item"
location = "location"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
email: Mapped[str] = mapped_column(String, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String)
role: Mapped[str] = mapped_column(String, default=Role.user.value)
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Game(Base):
__tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String)
seed: Mapped[int] = mapped_column(Integer)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Entry(Base):
__tablename__ = "entries"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
category: Mapped[str] = mapped_column(String, index=True)
label: Mapped[str] = mapped_column(String)
class SheetState(Base):
__tablename__ = "sheet_state"
__table_args__ = (
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True)
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'

View File

@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User, Role
from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"])
def require_admin(req: Request, db: Session) -> User:
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
user = db.query(User).filter(User.id == uid).first()
if not user or user.role != Role.admin.value:
raise HTTPException(status_code=403, detail="forbidden")
return user
@router.get("/users")
def list_users(req: Request, db: Session = Depends(get_db)):
require_admin(req, db)
users = db.query(User).order_by(User.created_at.desc()).all()
return [{"id": u.id, "email": u.email, "role": u.role, "disabled": u.disabled} for u in users]
@router.post("/users")
def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
email = (data.get("email") or "").lower().strip()
password = data.get("password") or ""
if not email or not password:
raise HTTPException(400, "email/password required")
if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists")
role = data.get("role") or Role.user.value
if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role")
u = User(email=email, password_hash=hash_password(password), role=role)
db.add(u); db.commit()
return {"ok": True, "id": u.id}

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User
from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
email = (data.get("email") or "").lower().strip()
password = data.get("password") or ""
user = db.query(User).filter(User.email == email, User.disabled == False).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="invalid credentials")
set_session(resp, make_session_value(user.id))
return {"ok": True, "role": user.role, "email": user.email}
@router.post("/logout")
def logout(resp: Response):
clear_session(resp)
return {"ok": True}
@router.get("/me")
def me(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
user = db.query(User).filter(User.id == uid).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role}

View File

@@ -0,0 +1,98 @@
import hashlib, random
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Game, Entry, SheetState, Category
from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"])
def require_user(req: Request, db: Session):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
return uid
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
s = f"{seed}:{user_id}:{entry_id}".encode()
return hashlib.sha256(s).hexdigest()
@router.post("")
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
name = data.get("name") or "Neues Spiel"
seed = random.randint(1, 2_000_000_000)
g = Game(owner_user_id=uid, name=name, seed=seed)
db.add(g); db.commit()
return {"id": g.id, "name": g.name}
@router.get("")
def list_games(req: Request, db: Session = Depends(get_db)):
uid = require_user(req, db)
games = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all()
return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games]
@router.get("/{game_id}/sheet")
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
if not g:
raise HTTPException(404, "game not found")
entries = db.query(Entry).all()
states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all()
state_map = {st.entry_id: st for st in states}
out = {"suspect": [], "item": [], "location": []}
for e in entries:
st = state_map.get(e.id)
item = {
"entry_id": e.id,
"label": e.label,
"status": st.status if st else 0,
"note_tag": st.note_tag if st else None,
"order": stable_order(g.seed, uid, e.id),
}
out[e.category].append(item)
# sort within category
for k in out:
out[k].sort(key=lambda x: x["order"])
for i in out[k]:
del i["order"]
return out
@router.patch("/{game_id}/sheet/{entry_id}")
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
if not g:
raise HTTPException(404, "game not found")
status = data.get("status")
note_tag = data.get("note_tag")
if note_tag not in (None, "i", "m", "s"):
raise HTTPException(400, "invalid note_tag")
if status is not None and status not in (0, 1, 2):
raise HTTPException(400, "invalid status")
st = db.query(SheetState).filter(
SheetState.game_id == g.id,
SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id
).first()
if not st:
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None)
db.add(st)
if status is not None:
st.status = status
if "note_tag" in data:
st.note_tag = note_tag
db.commit()
return {"ok": True}

48
backend/app/security.py Normal file
View File

@@ -0,0 +1,48 @@
import os, secrets
from passlib.context import CryptContext
from fastapi import Request, Response, HTTPException
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
COOKIE_NAME = "cluedo_session"
SECRET_KEY = os.environ["SECRET_KEY"]
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
COOKIE_SAMESITE = os.getenv("COOKIE_SAMESITE", "Lax")
# sehr simpel: Session-Token -> user_id in Memory ist zu wenig.
# Für MVP intern ok wäre token->user_id in DB/redis besser.
# Hier: signed cookie (token:user_id) minimal.
def hash_password(p: str) -> str:
return pwd.hash(p)
def verify_password(p: str, h: str) -> bool:
return pwd.verify(p, h)
def make_session_value(user_id: str) -> str:
sig = secrets.token_hex(16)
# signed-ish: store both + secret marker
return f"{user_id}.{sig}.{secrets.token_hex(8)}"
def set_session(resp: Response, value: str):
resp.set_cookie(
COOKIE_NAME,
value,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
max_age=60 * 60 * 24 * 30,
)
def clear_session(resp: Response):
resp.delete_cookie(COOKIE_NAME, path="/")
def get_session_user_id(req: Request) -> str | None:
val = req.cookies.get(COOKIE_NAME)
if not val:
return None
parts = val.split(".")
if len(parts) < 2:
return None
return parts[0]

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
SQLAlchemy==2.0.34
psycopg[binary]==3.2.2
passlib[bcrypt]==1.7.4
python-multipart==0.0.9

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SECRET_KEY: ${BACKEND_SECRET_KEY}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true
COOKIE_SAMESITE: "Lax"
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
frontend:
build: ./frontend
depends_on:
- backend
ports:
- "8081:80"
volumes:
pgdata:

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

8
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,8 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "cluedo-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}

152
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,152 @@
import React, { useEffect, useState } from "react";
const API = "http://localhost:8080";
async function api(path, opts = {}) {
const res = await fetch(API + path, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
function cycleTag(tag) {
if (!tag) return "i";
if (tag === "i") return "m";
if (tag === "m") return "s";
return null;
}
export default function App() {
const [me, setMe] = useState(null);
const [email, setEmail] = useState("admin@local");
const [password, setPassword] = useState("");
const [games, setGames] = useState([]);
const [gameId, setGameId] = useState(null);
const [sheet, setSheet] = useState(null);
const [tab, setTab] = useState("suspect");
const load = async () => {
const m = await api("/auth/me");
setMe(m);
const gs = await api("/games");
setGames(gs);
if (gs[0] && !gameId) setGameId(gs[0].id);
};
useEffect(() => {
(async () => {
try { await load(); } catch {}
})();
}, []);
useEffect(() => {
(async () => {
if (!gameId) return;
try {
const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh);
} catch {}
})();
}, [gameId]);
const doLogin = async () => {
await api("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) });
await load();
};
const newGame = async () => {
const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }) });
const gs = await api("/games");
setGames(gs);
setGameId(g.id);
};
const toggleCross = async (entry) => {
// Tap auf Name: unknown <-> crossed
const next = entry.status === 1 ? 0 : 1;
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ status: next }),
});
const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh);
};
const toggleTag = async (entry) => {
const next = cycleTag(entry.note_tag);
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: next }),
});
const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh);
};
if (!me) {
return (
<div style={{ padding: 16, fontFamily: "system-ui" }}>
<h2>Login</h2>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" style={{ width: "100%", padding: 10, marginBottom: 8 }} />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Passwort" type="password" style={{ width: "100%", padding: 10, marginBottom: 8 }} />
<button onClick={doLogin} style={{ width: "100%", padding: 12 }}>Anmelden</button>
<p style={{ opacity: 0.7, marginTop: 10 }}>Default Admin: admin@local (Passwort aus .env)</p>
</div>
);
}
const entries = sheet ? sheet[tab] : [];
return (
<div style={{ fontFamily: "system-ui", padding: 12, maxWidth: 520, margin: "0 auto" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontWeight: 700 }}>{me.email}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{me.role}</div>
</div>
<button onClick={newGame} style={{ padding: "10px 12px" }}>+ Neues Spiel</button>
</div>
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<select value={gameId || ""} onChange={(e) => setGameId(e.target.value)} style={{ flex: 1, padding: 10 }}>
{games.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
{["suspect","item","location"].map(t => (
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: 10, fontWeight: tab===t ? 700 : 500 }}>
{t === "suspect" ? "Verdächtige" : t === "item" ? "Gegenstand" : "Ort"}
</button>
))}
</div>
<div style={{ marginTop: 12, border: "1px solid #ddd", borderRadius: 12, overflow: "hidden" }}>
{entries.map((e) => (
<div key={e.entry_id} style={{ display: "grid", gridTemplateColumns: "1fr 40px 40px 40px 52px", gap: 6, padding: 10, borderBottom: "1px solid #eee", alignItems: "center" }}>
{/* Name: Tap toggelt crossed */}
<div onClick={() => toggleCross(e)} style={{ cursor: "pointer", textDecoration: e.status === 1 ? "line-through" : "none", userSelect: "none" }}>
{e.label}
</div>
{/* Spalte 1: X */}
<div style={{ textAlign: "center", fontWeight: 800 }}>{e.status === 1 ? "X" : ""}</div>
{/* Spalte 2: gelbes ✓ */}
<div style={{ textAlign: "center", fontWeight: 800 }}>{e.status === 1 ? "✓" : ""}</div>
{/* Spalte 3: grünes ✓ (für später, wenn du confirmed nutzt) */}
<div style={{ textAlign: "center", fontWeight: 800 }}>{e.status === 2 ? "✓" : ""}</div>
{/* Spalte 4: i/m/s */}
<button onClick={() => toggleTag(e)} style={{ padding: "8px 0", fontWeight: 800 }}>
{e.note_tag || "—"}
</button>
</div>
))}
</div>
</div>
);
}

5
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,5 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(<App />);

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});