Skip to content

Commit fa9b42f

Browse files
committed
Guest mode: grant sub-channels and voice rooms individually (#5401)
The guest-channels picker only listed top-level channels, and the auto-join cascaded to public sub-channels of a whitelisted parent. So private sub-channels were never reachable, and voice rooms the guest wasn't a member of failed with "Not a member of this channel" (the server already lets guests use voice once they're a member). Now the picker lists every channel individually — parents, sub-channels, and voice rooms — each with private/voice tags, grouped under their parent. Ticking a sub-channel auto-ticks its parent (a sub only renders nested under a visible parent); unticking a parent unticks its subs. The server joins exactly the selected ids and never cascades, so admins control precisely what guests see.
1 parent be2b7eb commit fa9b42f

2 files changed

Lines changed: 59 additions & 25 deletions

File tree

public/js/modules/app-ui.js

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4040,27 +4040,53 @@ _setupUI() {
40404040
const _renderGuestChannels = () => {
40414041
const host = document.getElementById('guest-channels-list');
40424042
if (!host) return;
4043-
const all = (this.channels || []).filter(c =>
4044-
!c.is_dm && !c.parent_channel_id
4045-
);
4046-
if (all.length === 0) {
4043+
const chans = (this.channels || []).filter(c => !c.is_dm);
4044+
if (chans.length === 0) {
40474045
host.innerHTML = '<p class="muted-text" style="margin:4px 0;font-size:0.85rem">No channels yet.</p>';
40484046
return;
40494047
}
40504048
// CSV of channel ids. Empty string = no channels (guests can log in but have nowhere to go).
4049+
// (#5401) Sub-channels and voice rooms are listed individually so admins
4050+
// grant guests exactly the channels they intend — no implicit cascade.
40514051
const raw = this.serverSettings?.guest_channels || '';
40524052
const selected = new Set(
40534053
raw.split(',').map(s => s.trim()).filter(Boolean).map(s => parseInt(s)).filter(Number.isFinite)
40544054
);
4055-
host.innerHTML = all.map(ch => {
4055+
4056+
const parents = chans.filter(c => !c.parent_channel_id)
4057+
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || String(a.name || '').localeCompare(String(b.name || '')));
4058+
const subsByParent = new Map();
4059+
chans.filter(c => c.parent_channel_id).forEach(c => {
4060+
if (!subsByParent.has(c.parent_channel_id)) subsByParent.set(c.parent_channel_id, []);
4061+
subsByParent.get(c.parent_channel_id).push(c);
4062+
});
4063+
for (const list of subsByParent.values()) {
4064+
list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || String(a.name || '').localeCompare(String(b.name || '')));
4065+
}
4066+
4067+
const tagsFor = (ch) => {
4068+
const tags = [];
4069+
if (ch.is_private || ch.code_visibility === 'private') tags.push('private');
4070+
if (ch.text_enabled === 0 && ch.voice_enabled) tags.push('voice');
4071+
return tags.length
4072+
? ` <small style="color:var(--text-muted)">(${tags.join(', ')})</small>` : '';
4073+
};
4074+
const row = (ch, isSub) => {
40564075
const checked = selected.has(ch.id);
4057-
const lockTag = (ch.is_private || ch.code_visibility === 'private')
4058-
? ' <small style="color:var(--text-muted)">(private)</small>' : '';
4059-
return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem">
4060-
<input type="checkbox" class="guest-channel-cb" data-cid="${ch.id}" ${checked ? 'checked' : ''}>
4061-
<span>#${this._escapeHtml(ch.name || '')}${lockTag}</span>
4076+
const prefix = isSub ? '↳ ' : '#';
4077+
const parentAttr = isSub ? ` data-parent="${ch.parent_channel_id}"` : '';
4078+
return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem${isSub ? ';margin-left:18px' : ''}">
4079+
<input type="checkbox" class="guest-channel-cb" data-cid="${ch.id}"${parentAttr} ${checked ? 'checked' : ''}>
4080+
<span>${prefix}${this._escapeHtml(ch.name || '')}${tagsFor(ch)}</span>
40624081
</label>`;
4063-
}).join('');
4082+
};
4083+
4084+
let html = '';
4085+
for (const p of parents) {
4086+
html += row(p, false);
4087+
for (const sub of (subsByParent.get(p.id) || [])) html += row(sub, true);
4088+
}
4089+
host.innerHTML = html;
40644090
const toggle = document.getElementById('guests-enabled');
40654091
if (toggle) toggle.checked = (this.serverSettings?.guests_enabled === 'true');
40664092
};
@@ -4075,6 +4101,21 @@ _setupUI() {
40754101
document.getElementById('guest-channels-none-btn')?.addEventListener('click', () => {
40764102
document.querySelectorAll('.guest-channel-cb').forEach(cb => { cb.checked = false; });
40774103
});
4104+
// A sub-channel only appears in the sidebar nested under its parent, so a
4105+
// guest needs the parent too. Keep the checkboxes consistent: ticking a sub
4106+
// ticks its parent; unticking a parent unticks its sub-channels. (#5401)
4107+
document.getElementById('guest-channels-list')?.addEventListener('change', (e) => {
4108+
const cb = e.target.closest?.('.guest-channel-cb');
4109+
if (!cb) return;
4110+
if (cb.checked && cb.dataset.parent) {
4111+
const parent = document.querySelector(`.guest-channel-cb[data-cid="${cb.dataset.parent}"]`);
4112+
if (parent) parent.checked = true;
4113+
}
4114+
if (!cb.checked && !cb.dataset.parent) {
4115+
document.querySelectorAll(`.guest-channel-cb[data-parent="${cb.dataset.cid}"]`)
4116+
.forEach(sub => { sub.checked = false; });
4117+
}
4118+
});
40784119
document.getElementById('guest-channels-save-btn')?.addEventListener('click', () => {
40794120
const cbs = Array.from(document.querySelectorAll('.guest-channel-cb'));
40804121
const picked = cbs.filter(cb => cb.checked).map(cb => parseInt(cb.dataset.cid)).filter(Number.isFinite);

src/auth.js

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -287,26 +287,19 @@ router.post('/guest-login', authLimiter, async (req, res) => {
287287
).run(username, hash);
288288
const userId = result.lastInsertRowid;
289289

290-
// Auto-join the admin-whitelisted guest channels.
291-
// If a parent channel is selected, also include its public sub-channels
292-
// so guests can actually open the nested rooms under that parent.
290+
// Auto-join exactly the admin-whitelisted guest channels. (#5401)
291+
// The admin picks each channel individually in the guest-channels picker —
292+
// top-level rooms, sub-channels, and voice rooms alike — so we join only
293+
// the listed ids and never cascade implicitly. This lets guests reach
294+
// nested and voice channels (previously skipped) while keeping the admin in
295+
// full control of what's exposed. DM channels are never joined.
293296
try {
294297
const chRow = db.prepare("SELECT value FROM server_settings WHERE key = 'guest_channels'").get();
295298
const csv = (chRow && typeof chRow.value === 'string') ? chRow.value.trim() : '';
296299
if (csv) {
297300
const seedIds = csv.split(',').map(s => parseInt(s.trim())).filter(n => Number.isInteger(n) && n > 0);
298-
const ids = new Set(seedIds);
299-
const parentRows = db.prepare('SELECT id FROM channels WHERE parent_channel_id IS NULL').all();
300-
const parentSet = new Set(parentRows.map(r => r.id));
301-
const subStmt = db.prepare('SELECT id FROM channels WHERE parent_channel_id = ? AND is_private = 0 AND is_dm = 0');
302-
for (const cid of seedIds) {
303-
if (!parentSet.has(cid)) continue;
304-
const subs = subStmt.all(cid);
305-
for (const sub of subs) ids.add(sub.id);
306-
}
307301
const insertMember = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)');
308-
for (const cid of ids) {
309-
// Never auto-join DM channels even if a stale id sneaks in.
302+
for (const cid of new Set(seedIds)) {
310303
const ch = db.prepare('SELECT id, is_dm FROM channels WHERE id = ?').get(cid);
311304
if (ch && !ch.is_dm) insertMember.run(cid, userId);
312305
}

0 commit comments

Comments
 (0)