feat(sharing): group-shared calendars in every member's sidebar, person share picker, hidden profiles

- 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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-06 16:35:11 +02:00
parent f76d2783d9
commit 79fdf4f54a
13 changed files with 245 additions and 23 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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()
)

View File

@@ -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())

View File

@@ -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 ─────────────────────────────────────────────────── */

View File

@@ -742,6 +742,13 @@
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-row">
<input type="checkbox" id="cfg-directory-hidden" />
<span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span>
</label>
<p class="panel-desc" data-i18n="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.</p>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
<div class="contrast-selector" id="cfg-event-duration">

View File

@@ -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 =>
`<div class="share-user-item" data-user-id="${u.id}">${escHtml(u.display_name || '')}</div>`
`<label class="share-user-item">
<input type="checkbox" class="share-user-cb" data-user-id="${u.id}" ${sharedIds.has(u.id) ? 'checked' : ''} />
<span>${escHtml(u.display_name || '')}</span>
</label>`
).join('')
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
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() {
<span class="cal-item-name">${escHtml(name)}</span>
</div>`;
};
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 += `<tr>
<td>${dot(cal.color, '#34a853')}${escHtml(owned ? cal.name : (cal.shared_by || cal.name))}</td>
<td class="ct-src">${owned ? 'Lokal' : 'Geteilt · ' + escHtml(cal.name)}</td>
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
<td class="ct-src">Lokal</td>
<td>—</td>
<td>${owned ? rem('local', cal.id, cal.reminders_enabled !== false) : '—'}</td>
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td>
<td>
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''}
${owned ? `<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>` : ''}
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>
</td>
<td>—</td>
<td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
</tr>`;
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 += `<tr class="ct-section-row"><td colspan="7">${t('shared_with_me')}</td></tr>`;
for (const cal of sharedWithMe) {
const readOnly = cal.permission !== 'read_write';
rows += `<tr>
<td>${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}</td>
<td class="ct-src">${escHtml(cal.name)} <span class="cal-badge">${readOnly ? t('perm_read') : t('perm_read_write')}</span></td>
<td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
</tr>`;
rowCount++;
}
}
if (!rowCount) {
rows = `<tr><td colspan="7" class="ct-empty">Keine Kalender vorhanden</td></tr>`;
}
@@ -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);

View File

@@ -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}',

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v70';
export const APP_VERSION = 'v71';