From 79fdf4f54a4802c04814645f4adb8ab66ed1d5e7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 6 Jul 2026 16:35:11 +0200 Subject: [PATCH] feat(sharing): group-shared calendars in every member's sidebar, person share picker, hidden profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: co-member group_visible calendars now surface in /local/calendars (owned=false, shared_by=owner, read-only, group_shared) and in the normal /caldav/events merge (via readable_local_calendar_ids), deduped against direct shares / group calendars so nothing appears twice. - Backend: new User.directory_hidden — a user can hide from sharing/group pickers (/users/directory), while admin user management (/users/) still lists them. Migration + profile GET/PUT. - Backend: /groups/{id} members carry shares_calendar so clients can drop phantom rows for members who share nothing. - Frontend: reachable "Teilen" button on owned local calendars; share modal is now a checkbox multi-select of users (checked = shared). Hidden-profile toggle in Settings → Profile. Group member filter only lists members who actually share (phantom fix). Calendars shared with me moved to a dedicated read-only "shared with me" section in the manage table. - Tests: group_visible propagation, no-share absence, dedup, directory_hidden. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 8 +++ backend/models.py | 3 ++ backend/permissions.py | 41 ++++++++++++++- backend/routers/groups_router.py | 11 ++++ backend/routers/local_router.py | 17 ++++++ backend/routers/profile_router.py | 6 +++ backend/routers/users_router.py | 5 +- backend/tests/test_collaboration.py | 75 +++++++++++++++++++++++++++ frontend/css/app.css | 9 ++++ frontend/index.html | 7 +++ frontend/js/calendar.js | 80 +++++++++++++++++++++-------- frontend/js/i18n.js | 4 ++ frontend/js/version.js | 2 +- 13 files changed, 245 insertions(+), 23 deletions(-) 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.

+

Standard-Termindauer

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index cae15ea..6819fc0 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -2682,31 +2682,42 @@ async function refreshShareModal(calendarId) { }); }); - // Store the directory (minus already-shared users) for the picker. - document.getElementById('modal-share').__users = users.filter(u => !sharedIds.has(u.id)); + // Store the full directory + which users are already shared, so the picker + // can show a checkbox per user (checked = shared). + const modal = document.getElementById('modal-share'); + modal.__users = users; + modal.__sharedIds = sharedIds; renderShareUserPicker(); } function renderShareUserPicker() { const modal = document.getElementById('modal-share'); const users = modal.__users || []; + const sharedIds = modal.__sharedIds || new Set(); const q = (document.getElementById('share-user-search').value || '').toLowerCase(); const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q)); const picker = document.getElementById('share-user-picker'); picker.innerHTML = filtered.length ? filtered.map(u => - `` + `` ).join('') : `${t('share_no_users')}`; - picker.querySelectorAll('.share-user-item').forEach(el => { - el.addEventListener('click', async () => { + picker.querySelectorAll('.share-user-cb').forEach(cb => { + cb.addEventListener('change', async () => { const calId = parseInt(modal.dataset.calId); - const permission = document.getElementById('share-permission').value; + const userId = parseInt(cb.dataset.userId); try { - await api.post(`/local/calendars/${calId}/shares`, - { user_id: parseInt(el.dataset.userId), permission }); + if (cb.checked) { + const permission = document.getElementById('share-permission').value; + await api.post(`/local/calendars/${calId}/shares`, { user_id: userId, permission }); + } else { + await api.delete(`/local/calendars/${calId}/shares/${userId}`); + } await refreshShareModal(calId); - } catch (e) { showToast(e.message, true); } + } catch (e) { showToast(e.message, true); cb.checked = !cb.checked; } }); }); } @@ -2802,7 +2813,11 @@ function renderGroupMemberFilter() { ${escHtml(name)}
`; }; - const parts = (g.members || []).map(m => row(m.id, String(m.id), m.display_name || '—', m.color)); + // Only members who actually share a calendar into the group get a toggle — + // otherwise we'd show phantom empty entries for members who share nothing. + const parts = (g.members || []) + .filter(m => m.shares_calendar) + .map(m => row(m.id, String(m.id), m.display_name || '—', m.color)); parts.push(row('gc', 'gc', t('group_calendar'), g.group_calendar_color)); items.innerHTML = parts.join(''); @@ -3117,6 +3132,8 @@ function openSettingsModal() { document.getElementById('cfg-login-name').value = pu.username || ''; api.get('/profile/').then(p => { document.getElementById('cfg-email').value = p.email || ''; + const dh = document.getElementById('cfg-directory-hidden'); + if (dh) dh.checked = !!p.directory_hidden; }).catch(() => {}); initAppPasswords(); @@ -3451,24 +3468,24 @@ function renderCalendarTable() { let rows = ''; - // Local calendars + // Local calendars I own. Calendars shared WITH me (owned === false) are shown + // in a separate "shared with me" section further down. for (const cal of state.localCalendars) { - if (cal.group) continue; - const owned = cal.owned !== false; - const canWrite = owned || cal.permission === 'read_write'; + if (cal.group || cal.owned === false) continue; const pub = !!cal.caldav_published; rows += ` - ${dot(cal.color, '#34a853')}${escHtml(owned ? cal.name : (cal.shared_by || cal.name))} - ${owned ? 'Lokal' : 'Geteilt · ' + escHtml(cal.name)} + ${dot(cal.color, '#34a853')}${escHtml(cal.name)} + Lokal — - ${owned ? rem('local', cal.id, cal.reminders_enabled !== false) : '—'} + ${rem('local', cal.id, cal.reminders_enabled !== false)} + - ${canWrite ? `` : ''} - ${owned ? `` : ''} + + — - ${owned ? `` : ''} + `; rowCount++; if (owned && pub) { @@ -3567,6 +3584,22 @@ function renderCalendarTable() { } } + // Calendars shared WITH me (direct shares + group-shared) — read-only, no + // management actions since I don't own them. Shown under the owner's name. + const sharedWithMe = state.localCalendars.filter(c => c.owned === false && !c.group); + if (sharedWithMe.length) { + rows += `${t('shared_with_me')}`; + for (const cal of sharedWithMe) { + const readOnly = cal.permission !== 'read_write'; + rows += ` + ${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)} + ${escHtml(cal.name)} ${readOnly ? t('perm_read') : t('perm_read_write')} + ————— + `; + rowCount++; + } + } + if (!rowCount) { rows = `Keine Kalender vorhanden`; } @@ -3684,6 +3717,11 @@ function renderCalendarTable() { btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId))); }); + // Share a calendar with specific users (person-to-person) + container.querySelectorAll('[data-ct-share]').forEach(btn => { + btn.addEventListener('click', () => openShareModal(parseInt(btn.dataset.ctShare))); + }); + // CalDAV publishing container.querySelectorAll('.ct-dav-toggle').forEach(btn => { btn.addEventListener('click', async () => { @@ -3892,6 +3930,8 @@ function bindSettingsModal() { const body = { email: email || null }; if (displayName) body.display_name = displayName; if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName; + const dh = document.getElementById('cfg-directory-hidden'); + if (dh) body.directory_hidden = dh.checked; try { const res = await api.put('/profile/', body); if (res && res.access_token) localStorage.setItem('token', res.access_token); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index c6a37dc..9cd31eb 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -91,6 +91,8 @@ const translations = { settings_privacy: 'Privatsphäre', settings_private_visibility: 'Private Termine für Gruppenmitglieder', settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen', + settings_directory_hidden: 'Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)', + settings_directory_hidden_desc: '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.', private_visibility_busy: 'Als „Beschäftigt“ anzeigen', private_visibility_hidden: 'Ausblenden', created_by: 'Erstellt von: {name}', @@ -406,6 +408,8 @@ const translations = { settings_privacy: 'Privacy', settings_private_visibility: 'Private events for group members', settings_private_visibility_desc: 'How your private events appear to other group members', + settings_directory_hidden: 'Hide my profile (don\'t show me in share/group pickers)', + settings_directory_hidden_desc: 'Other users then can\'t pick you to share calendars with or add you to groups. You remain visible in the admin user management.', private_visibility_busy: 'Show as "Busy"', private_visibility_hidden: 'Hide completely', created_by: 'Created by: {name}', diff --git a/frontend/js/version.js b/frontend/js/version.js index ffa524b..90af8b3 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v70'; +export const APP_VERSION = 'v71';