Add display_name support for users in backend and frontend
This commit introduces the `display_name` field to the user model. It updates database migrations, API endpoints, and the admin panel to handle this field. Additionally, the `display_name` is now shown in the TopBar and WinnerBadge components, improving user experience.
This commit is contained in:
@@ -86,6 +86,14 @@ Very small, pragmatic auto-migration (no alembic).
|
|||||||
- supports old schema (join_code/chip_code) and new schema (code/chip)
|
- supports old schema (join_code/chip_code) and new schema (code/chip)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# --- users.display_name ---
|
||||||
|
if not _has_column(db, "users", "display_name"):
|
||||||
|
try:
|
||||||
|
db.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR DEFAULT ''"))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
# --- users.theme_key ---
|
# --- users.theme_key ---
|
||||||
if not _has_column(db, "users", "theme_key"):
|
if not _has_column(db, "users", "theme_key"):
|
||||||
try:
|
try:
|
||||||
@@ -279,6 +287,7 @@ def ensure_admin(db: Session):
|
|||||||
password_hash=hash_password(admin_pw),
|
password_hash=hash_password(admin_pw),
|
||||||
role=Role.admin.value,
|
role=Role.admin.value,
|
||||||
theme_key="default",
|
theme_key="default",
|
||||||
|
display_name="Admin",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# backend/app/models.py
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -34,25 +35,24 @@ class User(Base):
|
|||||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# NEW: Theme im Userprofil (damit es auf anderen Geräten mitkommt)
|
|
||||||
theme_key: Mapped[str] = mapped_column(String, default="default")
|
theme_key: Mapped[str] = mapped_column(String, default="default")
|
||||||
|
|
||||||
|
# NEW: schöner Name für UI (TopBar / WinnerBadge)
|
||||||
|
display_name: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
|
||||||
class Game(Base):
|
class Game(Base):
|
||||||
__tablename__ = "games"
|
__tablename__ = "games"
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
# NEW: Host (nur Host darf Winner setzen)
|
|
||||||
host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String)
|
name: Mapped[str] = mapped_column(String)
|
||||||
seed: Mapped[int] = mapped_column(Integer)
|
seed: Mapped[int] = mapped_column(Integer)
|
||||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# NEW: Join-Code (Kahoot-Style)
|
|
||||||
code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||||
|
|
||||||
# NEW: Winner (aus Users, nicht Freitext)
|
|
||||||
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,18 +75,15 @@ class Entry(Base):
|
|||||||
|
|
||||||
class SheetState(Base):
|
class SheetState(Base):
|
||||||
__tablename__ = "sheet_state"
|
__tablename__ = "sheet_state"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),)
|
||||||
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()))
|
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)
|
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)
|
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)
|
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, 3 maybe
|
status: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||||
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
note_tag: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
# NEW: Chip persistieren (statt LocalStorage)
|
|
||||||
chip: Mapped[str | None] = mapped_column(String, nullable=True)
|
chip: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
@@ -19,21 +19,53 @@ def require_admin(req: Request, db: Session) -> User:
|
|||||||
def list_users(req: Request, db: Session = Depends(get_db)):
|
def list_users(req: Request, db: Session = Depends(get_db)):
|
||||||
require_admin(req, db)
|
require_admin(req, db)
|
||||||
users = db.query(User).order_by(User.created_at.desc()).all()
|
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]
|
return [
|
||||||
|
{
|
||||||
|
"id": u.id,
|
||||||
|
"email": u.email,
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"role": u.role,
|
||||||
|
"disabled": u.disabled,
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
@router.post("/users")
|
@router.post("/users")
|
||||||
def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
|
def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
require_admin(req, db)
|
require_admin(req, db)
|
||||||
email = (data.get("email") or "").lower().strip()
|
email = (data.get("email") or "").lower().strip()
|
||||||
password = data.get("password") or ""
|
password = data.get("password") or ""
|
||||||
|
display_name = (data.get("display_name") or "").strip()
|
||||||
|
|
||||||
if not email or not password:
|
if not email or not password:
|
||||||
raise HTTPException(400, "email/password required")
|
raise HTTPException(400, "email/password required")
|
||||||
if db.query(User).filter(User.email == email).first():
|
if db.query(User).filter(User.email == email).first():
|
||||||
raise HTTPException(409, "email exists")
|
raise HTTPException(409, "email exists")
|
||||||
|
|
||||||
role = data.get("role") or Role.user.value
|
role = data.get("role") or Role.user.value
|
||||||
if role not in (Role.admin.value, Role.user.value):
|
if role not in (Role.admin.value, Role.user.value):
|
||||||
raise HTTPException(400, "invalid role")
|
raise HTTPException(400, "invalid role")
|
||||||
u = User(email=email, password_hash=hash_password(password), role=role)
|
|
||||||
|
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
|
||||||
db.add(u); db.commit()
|
db.add(u); db.commit()
|
||||||
return {"ok": True, "id": u.id}
|
return {"ok": True, "id": u.id}
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
|
||||||
|
admin = require_admin(req, db)
|
||||||
|
|
||||||
|
if admin.id == user_id:
|
||||||
|
raise HTTPException(400, "cannot delete yourself")
|
||||||
|
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
|
||||||
|
if u.role == Role.admin.value:
|
||||||
|
raise HTTPException(400, "cannot delete admin user")
|
||||||
|
|
||||||
|
# soft delete
|
||||||
|
u.disabled = True
|
||||||
|
db.add(u)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -40,7 +40,8 @@ def me(req: Request, db: Session = Depends(get_db)):
|
|||||||
user = db.query(User).filter(User.id == uid).first()
|
user = db.query(User).filter(User.id == uid).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="not logged in")
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key}
|
return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/password")
|
@router.patch("/password")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default function AdminPanel() {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [role, setRole] = useState("user");
|
const [role, setRole] = useState("user");
|
||||||
@@ -22,6 +23,7 @@ export default function AdminPanel() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
setDisplayName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setRole("user");
|
setRole("user");
|
||||||
@@ -32,7 +34,7 @@ export default function AdminPanel() {
|
|||||||
try {
|
try {
|
||||||
await api("/admin/users", {
|
await api("/admin/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ email, password, role }),
|
body: JSON.stringify({ display_name: displayName, email, password, role }),
|
||||||
});
|
});
|
||||||
setMsg("✅ User erstellt.");
|
setMsg("✅ User erstellt.");
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
@@ -43,6 +45,16 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (u) => {
|
||||||
|
if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/users/${u.id}`, { method: "DELETE" });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Fehler: " + (e?.message || "unknown"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMsg("");
|
setMsg("");
|
||||||
@@ -63,14 +75,39 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
|
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<div key={u.id} style={styles.userRow}>
|
<div
|
||||||
<div style={{ color: stylesTokens.textMain }}>{u.email}</div>
|
key={u.id}
|
||||||
|
style={{
|
||||||
|
...styles.userRow,
|
||||||
|
gridTemplateColumns: "1fr 1fr 80px 90px 92px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
|
||||||
|
{u.display_name || "—"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
|
||||||
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
|
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
|
||||||
{u.role}
|
{u.role}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
|
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
|
||||||
{u.disabled ? "disabled" : "active"}
|
{u.disabled ? "disabled" : "active"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => deleteUser(u)}
|
||||||
|
style={{
|
||||||
|
...styles.secondaryBtn,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 12,
|
||||||
|
color: "#ffb3b3",
|
||||||
|
opacity: u.role === "admin" ? 0.4 : 1,
|
||||||
|
pointerEvents: u.role === "admin" ? "none" : "auto",
|
||||||
|
}}
|
||||||
|
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,13 +125,21 @@ export default function AdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Name (z.B. Sascha)"
|
||||||
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -102,6 +147,7 @@ export default function AdminPanel() {
|
|||||||
type="password"
|
type="password"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
|
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
|
||||||
<option value="user">user</option>
|
<option value="user">user</option>
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
@@ -125,7 +171,7 @@ export default function AdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||||
Tipp: Klick auf Item: Grün → Rot → Grau → Leer
|
Tipp: Name wird in TopBar & Siegeranzeige genutzt.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,4 +179,4 @@ export default function AdminPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from "react";
|
|||||||
import { styles } from "../styles/styles";
|
import { styles } from "../styles/styles";
|
||||||
import { stylesTokens } from "../styles/theme";
|
import { stylesTokens } from "../styles/theme";
|
||||||
|
|
||||||
|
const displayName = (me?.display_name || "").trim() || me.email;
|
||||||
|
|
||||||
export default function TopBar({
|
export default function TopBar({
|
||||||
me,
|
me,
|
||||||
userMenuOpen,
|
userMenuOpen,
|
||||||
@@ -18,7 +20,7 @@ export default function TopBar({
|
|||||||
Notizbogen
|
Notizbogen
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||||
{me.email}
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { stylesTokens } from "../styles/theme";
|
import { stylesTokens } from "../styles/theme";
|
||||||
|
|
||||||
export default function WinnerBadge({ winnerEmail }) {
|
/**
|
||||||
if (!winnerEmail) return null;
|
* Props:
|
||||||
|
* - winner: { display_name?: string, email?: string } | null
|
||||||
|
* (oder als Fallback:)
|
||||||
|
* - winnerEmail: string | null
|
||||||
|
*/
|
||||||
|
export default function WinnerBadge({ winner, winnerEmail }) {
|
||||||
|
const name =
|
||||||
|
(winner?.display_name || "").trim() ||
|
||||||
|
(winner?.email || "").trim() ||
|
||||||
|
(winnerEmail || "").trim();
|
||||||
|
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
// Optional: wenn display_name vorhanden ist, Email klein anzeigen
|
||||||
|
const showEmail =
|
||||||
|
winner &&
|
||||||
|
(winner?.email || "").trim() &&
|
||||||
|
(winner?.display_name || "").trim() &&
|
||||||
|
winner.email.trim().toLowerCase() !== winner.display_name.trim().toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -22,9 +40,18 @@ export default function WinnerBadge({ winnerEmail }) {
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div style={{ fontSize: 18 }}>🏆</div>
|
<div style={{ fontSize: 18 }}>🏆</div>
|
||||||
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
|
|
||||||
Sieger:
|
<div style={{ display: "grid", gap: 2 }}>
|
||||||
<span style={{ color: stylesTokens.textGold }}>{" "}{winnerEmail}</span>
|
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
|
||||||
|
Sieger:
|
||||||
|
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmail && (
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||||
|
{winner.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user