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

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