diff --git a/backend/main.py b/backend/main.py index 7ea0e53..815816e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -244,6 +244,14 @@ def _migrate(): except Exception: pass + # Hide a user from sharing/group pickers (admin management still shows them). + try: + conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0")) + conn.commit() + logging.info("Migration: added directory_hidden to users") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 9edcc10..0d35fe6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -17,6 +17,9 @@ class User(Base): avatar_filename = Column(String(255), nullable=True) totp_secret = Column(String(32), nullable=True) totp_enabled = Column(Boolean, default=False) + # When true, the user is hidden from sharing/group picker directories + # (/users/directory). Admin user management (/users/) still shows them. + directory_hidden = Column(Boolean, default=False, nullable=False) caldav_accounts = relationship( "CalDAVAccount", back_populates="user", cascade="all, delete-orphan" diff --git a/backend/permissions.py b/backend/permissions.py index 3c86c3b..6988371 100644 --- a/backend/permissions.py +++ b/backend/permissions.py @@ -97,8 +97,44 @@ def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> model return cal +def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]: + """Calendars that co-members of the user's groups share into the group. + + Each user designates ONE of their own calendars via + UserSettings.group_visible_calendar_id. This returns those calendars for + every co-member of any group the user belongs to (excluding the user's own). + The calendar must be owned by the designating member. Deduped (each calendar + appears once even across multiple shared groups). + """ + my_group_ids = ( + db.query(models.GroupMember.group_id) + .filter(models.GroupMember.user_id == user.id) + ) + co_member_ids = ( + db.query(models.GroupMember.user_id) + .filter( + models.GroupMember.group_id.in_(my_group_ids), + models.GroupMember.user_id != user.id, + ) + .distinct() + ) + return ( + db.query(models.LocalCalendar) + .join( + models.UserSettings, + models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id, + ) + .filter( + models.UserSettings.user_id.in_(co_member_ids), + # the designating member must own the calendar they share + models.LocalCalendar.user_id == models.UserSettings.user_id, + ) + .all() + ) + + def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: - """All local calendar ids the user may read: own + shared + group calendars.""" + """All local calendar ids the user may read: own + shared + group + co-member group-visible.""" ids: set[int] = set() own = ( @@ -123,4 +159,7 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: ) ids.update(r[0] for r in group_cals) + # Calendars co-members share into shared groups (group-visible). + ids.update(c.id for c in co_member_group_visible_calendars(db, user)) + return list(ids) diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index f87b0f0..5a8940d 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -176,11 +176,22 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) - member_dicts = [] for i, m in enumerate(members): u = db.query(models.User).filter(models.User.id == m.user_id).first() + # Whether this member actually shares a calendar into the group (owns a + # calendar designated as their group_visible). Lets clients hide phantom + # empty rows for members who share nothing. + s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first() + shares_calendar = False + if s and s.group_visible_calendar_id is not None: + shares_calendar = db.query(models.LocalCalendar.id).filter( + models.LocalCalendar.id == s.group_visible_calendar_id, + models.LocalCalendar.user_id == m.user_id, + ).first() is not None member_dicts.append({ "id": m.user_id, "display_name": (u.display_name or u.username) if u else None, "role": m.role, "color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)], + "shares_calendar": shares_calendar, }) gcal_id = _group_calendar_id(db, group.id) return { diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index b5fe0e4..7076616 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -167,6 +167,23 @@ def list_calendars( d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write") d["group"] = True result.append(d) + + # Calendars co-members share into shared groups (group_visible_calendar_id). + # Read-only, shown under the owner's name. Deduped against everything above + # so a calendar already shared directly / as a group calendar isn't doubled. + for cal in permissions.co_member_group_visible_calendars(db, current_user): + if cal.id in seen_ids: + continue + seen_ids.add(cal.id) + owner = db.query(models.User).filter(models.User.id == cal.user_id).first() + d = _cal_dict( + cal, owned=False, + shared_by=(owner.display_name or owner.username) if owner else None, + permission="read", + request=request, + ) + d["group_shared"] = True + result.append(d) return result diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py index d78a52b..48b25b9 100644 --- a/backend/routers/profile_router.py +++ b/backend/routers/profile_router.py @@ -34,6 +34,7 @@ class ProfileUpdate(BaseModel): email: Optional[str] = Field(default=None, max_length=120) display_name: Optional[str] = Field(default=None, max_length=80) username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase) + directory_hidden: Optional[bool] = None # hide from sharing/group pickers def _strip_controls(s: str) -> str: @@ -66,6 +67,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)): "is_admin": current_user.is_admin, "has_avatar": current_user.avatar_filename is not None, "totp_enabled": current_user.totp_enabled, + "directory_hidden": bool(current_user.directory_hidden), } @@ -109,11 +111,15 @@ def update_profile( if taken: raise HTTPException(400, "Username already taken") current_user.username = new_login + if data.directory_hidden is not None: + current_user.directory_hidden = data.directory_hidden 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 + if data.directory_hidden is not None: + current_user.directory_hidden = data.directory_hidden db.commit() return result diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py index 3c4831a..e81d803 100644 --- a/backend/routers/users_router.py +++ b/backend/routers/users_router.py @@ -53,7 +53,10 @@ def user_directory( """ users = ( db.query(models.User) - .filter(models.User.id != current_user.id) + .filter( + models.User.id != current_user.id, + models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers + ) .order_by(models.User.username) .all() ) diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index 2f2a89c..6333d7d 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -409,3 +409,78 @@ def test_import_export_only_local(client): cal_id = _make_calendar(client, admin, "Privat") # Bob has no access -> 404 on export. assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404 + + +# ── Group-visible calendars propagate to co-members' sidebars ───────────── + +def test_group_visible_propagates_to_member_sidebar(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}) + + # Bob designates a calendar as group-visible; admin (co-member) should see it. + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + _make_event(client, b_tok, b_cal, "Bobs Termin") + + cals = client.get("/api/local/calendars", headers=auth(admin)).json() + shared = [c for c in cals if c["id"] == b_cal] + assert len(shared) == 1, shared + assert shared[0]["owned"] is False + assert shared[0]["shared_by"] == "bob" + assert shared[0]["permission"] == "read" + assert shared[0].get("group_shared") is True + + # Its events appear in the normal merged read, read-only. + events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"] + bob_ev = [e for e in events if e["title"] == "Bobs Termin"] + assert bob_ev and bob_ev[0].get("read_only") is True + + +def test_group_visible_absent_when_not_designated(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}) + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated + cals = client.get("/api/local/calendars", headers=auth(admin)).json() + assert not any(c["id"] == b_cal for c in cals) + + +def test_group_visible_not_duplicated_with_direct_share(client): + admin = register_admin(client) + admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"] + b_id, b_tok = create_user(client, admin, "bob") + client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}) + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + # Also directly shared with admin (read_write) — must not double-list. + client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok), + json={"user_id": admin_id, "permission": "read_write"}) + cals = client.get("/api/local/calendars", headers=auth(admin)).json() + matches = [c for c in cals if c["id"] == b_cal] + assert len(matches) == 1, matches + # The direct share wins (read_write, not flagged group_shared). + assert matches[0]["permission"] == "read_write" + assert matches[0].get("group_shared") is not True + + +# ── Hidden profile (directory opt-out) ──────────────────────────────────── + +def test_directory_hidden_excludes_from_picker_but_not_admin(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + + assert any(u["id"] == b_id for u in + client.get("/api/users/directory", headers=auth(admin)).json()) + + r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True}) + assert r.status_code == 200, r.text + + assert not any(u["id"] == b_id for u in + client.get("/api/users/directory", headers=auth(admin)).json()) + # Admin user management still lists the hidden user. + assert any(u["id"] == b_id for u in + client.get("/api/users/", headers=auth(admin)).json()) diff --git a/frontend/css/app.css b/frontend/css/app.css index 5e34cb4..8afa9f7 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -2002,12 +2002,21 @@ a { color: var(--primary); text-decoration: none; } border-radius: 10px; } .share-user-item { + display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--border); } .share-user-item:last-child { border-bottom: none; } .share-user-item:hover { background: var(--bg-surface); } +.share-user-item input[type=checkbox] { flex-shrink: 0; } +/* "Shared with me" section header row inside the calendar-management table */ +.ct-section-row td { + padding-top: 14px; font-size: 12px; font-weight: 600; + color: var(--text-3); text-transform: uppercase; letter-spacing: .04em; +} +.checkbox-row { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.checkbox-row input[type=checkbox] { flex-shrink: 0; } /* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */ /* ── Groups ─────────────────────────────────────────────────── */ diff --git a/frontend/index.html b/frontend/index.html index 1266c9e..1e236ae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -742,6 +742,13 @@ +
Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.
+