From f9923b022e3e5bd0ba6a05e2aecdb7064c79e0aa Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 17:40:38 +0200 Subject: [PATCH] feat: Login-Name vs. Anzeigename (Server) - Neue Spalte users.display_name (Original-Schreibweise); username bleibt der lowercase Login-Name. Setup/Create setzen display_name aus der Eingabe. - Login bleibt case-insensitive (Anzeigename eingeben funktioniert -> wird lowercased -> trifft den Login-Namen). - Profil: PUT /api/profile/ kann display_name UND username (Login-Name) aendern; bei Login-Namen-Wechsel kommt ein frischer Token zurueck (JWT sub haengt am Namen). Stabile interne ID (Integer-PK) traegt alle Verweise -> Umbenennen bricht Shares/Gruppen/creator_id nicht. - display_name ueberall ausgeliefert/genutzt (me, profile, users, directory, shares, Gruppen-Mitglieder, creator/owner, ORGANIZER-Export). - Migration + Backfill (display_name = username). Tests ergaenzt (17 gruen). Co-Authored-By: Claude Opus 4.8 --- backend/local_events_util.py | 2 +- backend/main.py | 12 +++++++++++ backend/models.py | 9 +++++--- backend/routers/auth_router.py | 9 +++++++- backend/routers/caldav_router.py | 2 +- backend/routers/groups_router.py | 4 ++-- backend/routers/local_router.py | 6 +++--- backend/routers/profile_router.py | 32 +++++++++++++++++++++++++++-- backend/routers/users_router.py | 11 ++++++++-- backend/tests/test_collaboration.py | 31 ++++++++++++++++++++++++++++ 10 files changed, 103 insertions(+), 15 deletions(-) diff --git a/backend/local_events_util.py b/backend/local_events_util.py index 1a88c08..4dcd7ce 100644 --- a/backend/local_events_util.py +++ b/backend/local_events_util.py @@ -36,7 +36,7 @@ def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) if name_cache is not None: display = name_cache.get(ev.creator_id) if display is None and ev.creator is not None: - display = ev.creator.username + display = ev.creator.display_name or ev.creator.username if display is not None: return {"id": ev.creator_id, "display_name": display} if ev.creator_name_external: diff --git a/backend/main.py b/backend/main.py index 5fca9f3..027e1c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,6 +163,18 @@ def _migrate(): logging.info("Migration: added group_visible_calendar_id to user_settings") except Exception: pass + try: + conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)")) + conn.commit() + logging.info("Migration: added display_name to users") + except Exception: + pass + # Backfill display_name from username for existing rows (only where empty). + try: + conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''")) + conn.commit() + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index a8a6011..2ace8c2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -7,7 +7,10 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) + # Login name: always lowercase, unique, used for authentication. username = Column(String(50), unique=True, nullable=False) + # Human-facing name with original casing; editable. Falls back to username. + display_name = Column(String(100), nullable=True) email = Column(String(100), unique=True, nullable=True) password_hash = Column(String(255), nullable=False) is_admin = Column(Boolean, default=False) @@ -35,9 +38,9 @@ class User(Base): ) @property - def display_name(self) -> str: - """No dedicated display-name column exists — fall back to the username.""" - return self.username + def display(self) -> str: + """The name to show users: display_name if set, else the login name.""" + return self.display_name or self.username class CalDAVAccount(Base): diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index 98ede29..3aa16bd 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -32,7 +32,12 @@ class LoginRequest(BaseModel): def _user_dict(user: models.User) -> dict: - return {"id": user.id, "username": user.username, "is_admin": user.is_admin} + return { + "id": user.id, + "username": user.username, + "display_name": user.display_name or user.username, + "is_admin": user.is_admin, + } @router.get("/setup-required") @@ -46,6 +51,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)): raise HTTPException(400, "Setup already completed") user = models.User( username=req.username.lower(), + display_name=req.username.strip(), # keep the original casing for display email=req.email, password_hash=get_password_hash(req.password), is_admin=True, @@ -113,6 +119,7 @@ def me(current_user: models.User = Depends(get_current_user)): return { "id": current_user.id, "username": current_user.username, + "display_name": current_user.display_name or current_user.username, "email": current_user.email, "is_admin": current_user.is_admin, "has_avatar": current_user.avatar_filename is not None, diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 63cdac8..d4501ae 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -334,7 +334,7 @@ def get_events( ) .all() ) if readable_ids else [] - name_cache = {u.id: u.username for u in db.query(models.User).all()} + name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} for local_cal in local_calendars: local_events = ( db.query(models.LocalEvent) diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index c427866..bc72b65 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -149,7 +149,7 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) - u = db.query(models.User).filter(models.User.id == m.user_id).first() member_dicts.append({ "id": m.user_id, - "display_name": u.username if u else None, + "display_name": (u.display_name or u.username) if u else None, "role": m.role, }) return { @@ -264,7 +264,7 @@ def combined_events( end_dt = end_dt.replace(tzinfo=timezone.utc) members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all() - name_cache = {u.id: u.username for u in db.query(models.User).all()} + name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} visibility_cache: dict[int, str] = {} def visibility_for(user_id: int) -> str: diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 7060f18..cbb6e33 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -134,7 +134,7 @@ def list_calendars( owner = db.query(models.User).filter(models.User.id == cal.user_id).first() d = _cal_dict( cal, owned=False, - shared_by=owner.username if owner else None, + shared_by=(owner.display_name or owner.username) if owner else None, permission=share.permission, ) if cal.id in group_cal_map: @@ -330,7 +330,7 @@ def list_shares( u = db.query(models.User).filter(models.User.id == s.user_id).first() out.append({ "user_id": s.user_id, - "display_name": u.username if u else None, + "display_name": (u.display_name or u.username) if u else None, "permission": s.permission, "created_at": s.created_at, }) @@ -501,7 +501,7 @@ def export_calendar( .all() ) # Resolve creator display names for ORGANIZER. - name_cache = {u.id: u.username for u in db.query(models.User).all()} + name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} ics = ical_io.build_ics(cal, events, name_cache=name_cache) safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar" return Response( diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py index 564410b..257aa67 100644 --- a/backend/routers/profile_router.py +++ b/backend/routers/profile_router.py @@ -11,8 +11,10 @@ from PIL import Image from pydantic import BaseModel from sqlalchemy.orm import Session +from sqlalchemy import func + import models -from auth import get_current_user, get_password_hash, verify_password +from auth import create_access_token, get_current_user, get_password_hash, verify_password from database import DATA_DIR, get_db router = APIRouter() @@ -26,6 +28,8 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} # ── Schemas ─────────────────────────────────────────────── class ProfileUpdate(BaseModel): email: Optional[str] = None + display_name: Optional[str] = None + username: Optional[str] = None # login name (stored lowercase) class PasswordChange(BaseModel): @@ -47,6 +51,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)): return { "id": current_user.id, "username": current_user.username, + "display_name": current_user.display_name or current_user.username, "email": current_user.email, "is_admin": current_user.is_admin, "has_avatar": current_user.avatar_filename is not None, @@ -60,10 +65,33 @@ def update_profile( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): + result = {"ok": True} if data.email is not None: current_user.email = data.email or None + if data.display_name is not None: + dn = data.display_name.strip() + current_user.display_name = dn or current_user.username + if data.username is not None: + new_login = data.username.strip().lower() + if not new_login: + raise HTTPException(422, "Login name cannot be empty") + if new_login != current_user.username: + taken = ( + db.query(models.User) + .filter(func.lower(models.User.username) == new_login, + models.User.id != current_user.id) + .first() + ) + if taken: + raise HTTPException(400, "Username already taken") + current_user.username = new_login + db.commit() + # The JWT 'sub' is the login name — renaming it invalidates the old + # token, so hand back a fresh one for the client to store. + result["access_token"] = create_access_token({"sub": new_login}) + return result db.commit() - return {"ok": True} + return result # ── Avatar ──────────────────────────────────────────────── diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py index 35fbd08..3c4831a 100644 --- a/backend/routers/users_router.py +++ b/backend/routers/users_router.py @@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel): def _user_dict(u: models.User) -> dict: - return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin} + return { + "id": u.id, + "username": u.username, + "display_name": u.display_name or u.username, + "email": u.email, + "is_admin": u.is_admin, + } @router.get("/") @@ -51,7 +57,7 @@ def user_directory( .order_by(models.User.username) .all() ) - return [{"id": u.id, "display_name": u.username} for u in users] + return [{"id": u.id, "display_name": u.display_name or u.username} for u in users] @router.post("/") @@ -64,6 +70,7 @@ def create_user( raise HTTPException(400, "Username already taken") user = models.User( username=req.username.lower(), + display_name=req.username.strip(), # keep the original casing for display email=req.email, password_hash=get_password_hash(req.password), is_admin=req.is_admin, diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index a93bae5..a44d0a3 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -225,6 +225,37 @@ def test_member_calendar_hidden_until_designated(client): assert any(e["title"] == "Bobs Termin" for e in seen2) +def test_display_name_case_preserved_and_login_case_insensitive(client): + # Setup with mixed-case name: login name lowercased, display name kept. + r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"}) + assert r.status_code == 200, r.text + user = r.json()["user"] + assert user["username"] == "guido" + assert user["display_name"] == "Guido" + + # Login is case-insensitive (typing the display name "GUIDO" works). + r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"}) + assert r2.status_code == 200, r2.text + tok = r2.json()["access_token"] + + # /me reflects the cased display name. + me = client.get("/api/auth/me", headers=auth(tok)).json() + assert me["display_name"] == "Guido" and me["username"] == "guido" + + +def test_rename_login_name_returns_new_token(client): + admin = register_admin(client, "alice") + # Change display name (no token change). + r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."}) + assert r.status_code == 200 and "access_token" not in r.json() + # Change login name -> fresh token, references survive (id is stable). + r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"}) + assert r2.status_code == 200 and r2.json().get("access_token") + new_tok = r2.json()["access_token"] + me = client.get("/api/auth/me", headers=auth(new_tok)).json() + assert me["username"] == "alice2" and me["display_name"] == "Alice W." + + def test_private_visibility_validation(client): admin = register_admin(client) r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})