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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v70';
|
||||
export const APP_VERSION = 'v71';
|
||||
|
||||
Reference in New Issue
Block a user