Skip to content

Commit a5baff8

Browse files
committed
channels: per-channel default role auto-grant (#5389)
1 parent 8ca6349 commit a5baff8

7 files changed

Lines changed: 162 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
1414
## [Unreleased]
1515

1616
### Added
17+
- **#5389: Per-channel default role.** Channel Functions → "Default Role" picks a server role that gets auto-granted (channel-scoped) to every current member and to anyone who joins later. Setting it backfills all existing members in one transaction; clearing it leaves prior grants in place so admins can decide whether to revoke from the Roles UI. New `channels.default_role_id` column (nullable FK → roles, SET NULL on delete), new `set-channel-default-role` socket event gated on `manage_roles`, and the auto-grant fires through the public-join, server-code, and vanity-code join paths. DMs are excluded since they have no roles. `INSERT OR IGNORE` on `user_roles (user_id, role_id, channel_id)` keeps repeated joins idempotent.
1718
- **#5392: Admin-adjustable max sticker file size.** Stickers had a hard-coded 1 MB ceiling that made them feel cramped compared to images — small enough that most "GIF library" candidates failed to upload. Admin Settings → Uploads & Limits now has a "Max Sticker File Size (KB)" input (256–10240 KB, default 1024). The server already read `max_sticker_kb` per-upload via `createStickerUpload()`, so this just surfaces and validates the setting. Bump it up if you want stickers to double as a GIF library.
1819
- **#5393: `/break` slash command + persona compacting hard-stop.** Different personas sent in quick succession under the same account were sometimes still visually compacting into a single grouped block for *other* viewers (not the poster — they saw it correctly), making the personas indistinguishable. Three defensive layers now: (1) the grouping check also compares `persona_username` and the displayed `username`, so even if a stale or missing `persona_id` slips through the wire the displayed name still forces a break; (2) a new `break_chain` column on `messages` lets any message hard-stop compaction with the previous one; (3) the new `/break <message>` slash command (also surfaced in the autocomplete list) lets users manually force a fresh group whenever they want, including for normal non-persona messages. The flag round-trips through the SELECT projections, the broadcast object, and the rendered DOM data-attrs so it survives reconnects, history pagination, and DOM trimming.
1920
- **#5300: Admin password reset (opt-in) backend.** New `admin_password_reset_enabled` server setting (default `false`) lets admins enable a "reset user password to a one-time temp value" flow. New socket event `admin-reset-user-password` (admin-only, gated on the setting) generates a 16-hex-char temp password (`XXXX-XXXX-XXXX-XXXX`), bcrypt-hashes it, bumps `password_version` (which invalidates the target's existing JWTs via the existing pwv-rejection path), sets a new `must_change_password` flag on the user row, and returns the plaintext temp password to the admin once. Audit-logged as `admin_password_reset`. Login response now carries `mustChangePassword: bool`, and a new `POST /api/auth/change-password-required` endpoint accepts a valid token + new password and clears the flag. The setting is also mirrored into `/api/public-config` so any user can see whether admins on this server have this power before signing up. Admin Settings has a checkbox + warning text covering the E2E impact (the user's wrap key derives from the password, so old encrypted DM history becomes unrecoverable on their side, matching the existing recovery-codes flow). Backend by @Amnibro (#5318).

public/app.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2812,6 +2812,10 @@ <h3>⚙ <span data-i18n="modals.manage_servers.title">Manage Servers</span></h3>
28122812
<span class="cfn-label">📢 <span data-i18n="channel_functions.announcement">Announcement</span></span>
28132813
<span class="cfn-badge cfn-off" data-i18n="channel_functions.off">OFF</span>
28142814
</div>
2815+
<div class="cfn-row" data-fn="default-role" data-i18n-title="channel_functions.default_role_hint" title="Auto-grant this role (scoped to this channel) to every current and future member">
2816+
<span class="cfn-label">👑 <span data-i18n="channel_functions.default_role">Default Role</span></span>
2817+
<span class="cfn-badge cfn-off" data-i18n="channel_functions.none">None</span>
2818+
</div>
28152819
<div class="cfn-divider cfn-afk-divider" style="display:none"></div>
28162820
<div class="cfn-row cfn-afk-row" data-fn="afk-sub" style="display:none" data-i18n-title="channel_functions.afk_sub_hint" title="Designate a sub-channel as the AFK voice room — idle users are moved here">
28172821
<span class="cfn-label">💤 <span data-i18n="channel_functions.afk_sub">AFK Sub</span></span>

public/js/modules/app-channels.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,18 @@ _updateChannelFunctionsPanel(ch) {
496496
// Announcement channel
497497
const isAnnouncement = ch.notification_type === 'announcement';
498498
this._setCfnBadge('announcement', isAnnouncement, isAnnouncement ? 'ON' : 'OFF');
499+
// (#5389) Default role badge — show role name when set, else "None".
500+
// Hide for DMs since DMs have no role concept.
501+
const defaultRoleRow = document.querySelector('.cfn-row[data-fn="default-role"]');
502+
if (defaultRoleRow) {
503+
defaultRoleRow.style.display = ch.is_dm ? 'none' : '';
504+
if (!ch.is_dm) {
505+
const drId = ch.default_role_id || null;
506+
const role = drId && Array.isArray(this._allRoles)
507+
? this._allRoles.find(r => r.id === drId) : null;
508+
this._setCfnBadge('default-role', !!drId, role ? role.name : (drId ? `#${drId}` : 'None'));
509+
}
510+
}
499511
// Self Destruct timer
500512
const hasExpiry = !!ch.expires_at;
501513
if (hasExpiry) {

public/js/modules/app-ui.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,53 @@ _setupUI() {
460460
const newType = isAnnouncement ? 'default' : 'announcement';
461461
optimistic({ notification_type: newType });
462462
this.socket.emit('set-notification-type', { code, type: newType });
463+
} else if (fn === 'default-role') {
464+
// (#5389) Dropdown of available server roles. Selecting one fires
465+
// set-channel-default-role; selecting "None" clears the default.
466+
if (row.querySelector('.cfn-select')) return;
467+
const badge = row.querySelector('.cfn-badge');
468+
if (!badge) return;
469+
// Lazy-fetch roles if we haven't yet (e.g. admin opened the panel
470+
// before visiting the Roles page).
471+
const _open = () => {
472+
const roles = Array.isArray(this._allRoles) ? this._allRoles : [];
473+
const select = document.createElement('select');
474+
select.className = 'cfn-select cfn-input';
475+
select.onclick = e2 => e2.stopPropagation();
476+
const noneOpt = document.createElement('option');
477+
noneOpt.value = ''; noneOpt.textContent = 'None';
478+
select.appendChild(noneOpt);
479+
for (const r of roles) {
480+
const opt = document.createElement('option');
481+
opt.value = String(r.id);
482+
opt.textContent = r.name;
483+
if (ch && r.id === ch.default_role_id) opt.selected = true;
484+
select.appendChild(opt);
485+
}
486+
badge.replaceWith(select);
487+
select.focus();
488+
let committed = false;
489+
const commit = () => {
490+
if (committed) return;
491+
committed = true;
492+
const raw = select.value;
493+
const roleId = raw ? parseInt(raw, 10) : null;
494+
optimistic({ default_role_id: roleId });
495+
this.socket.emit('set-channel-default-role', { code, roleId });
496+
};
497+
select.addEventListener('change', () => { commit(); select.blur(); });
498+
select.addEventListener('blur', () => {
499+
if (!committed) this._updateChannelFunctionsPanel(ch);
500+
});
501+
};
502+
if (Array.isArray(this._allRoles) && this._allRoles.length) {
503+
_open();
504+
} else {
505+
this.socket.emit('get-roles', {}, (res) => {
506+
if (res && Array.isArray(res.roles)) this._allRoles = res.roles;
507+
_open();
508+
});
509+
}
463510
} else if (fn === 'user-limit') {
464511
// If an input is already showing, don't open another
465512
if (row.querySelector('.cfn-input')) return;

src/database.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,15 @@ function initDatabase() {
412412
try { db.prepare(`SELECT ${col.name} FROM channels LIMIT 0`).get(); } catch { db.exec(col.sql); }
413413
}
414414

415+
// ── Migration: per-channel default role (#5389) ──────────
416+
// When set, every existing and future member of this channel is granted
417+
// this role scoped to this channel via user_roles. NULL = no auto-grant.
418+
try {
419+
db.prepare("SELECT default_role_id FROM channels LIMIT 0").get();
420+
} catch {
421+
db.exec("ALTER TABLE channels ADD COLUMN default_role_id INTEGER DEFAULT NULL REFERENCES roles(id) ON DELETE SET NULL");
422+
}
423+
415424
// ── Migration: sub-channels (parent_channel_id, position) ──
416425
try {
417426
db.prepare("SELECT parent_channel_id FROM channels LIMIT 0").get();

src/socketHandlers/channels.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ module.exports = function register(socket, ctx) {
1414
const { channelUsers, voiceUsers, activeMusic, musicQueues } = state;
1515
const _audit = (typeof logAudit === 'function') ? logAudit : () => {};
1616

17+
// (#5389) Auto-grant a channel's default_role_id to a user when they join
18+
// or are added to that channel. No-op if the channel has no default role.
19+
// Safe to call repeatedly — INSERT OR IGNORE avoids duplicates.
20+
const _applyChannelDefaultRole = (channelId, userId, grantedBy = null) => {
21+
try {
22+
const row = db.prepare('SELECT default_role_id FROM channels WHERE id = ?').get(channelId);
23+
if (!row || !row.default_role_id) return;
24+
db.prepare(
25+
'INSERT OR IGNORE INTO user_roles (user_id, role_id, channel_id, granted_by) VALUES (?, ?, ?, ?)'
26+
).run(userId, row.default_role_id, channelId, grantedBy);
27+
if (typeof applyRoleChannelAccess === 'function') {
28+
try { applyRoleChannelAccess(row.default_role_id, userId, 'grant'); } catch { /* non-critical */ }
29+
}
30+
} catch (e) {
31+
console.warn('applyChannelDefaultRole failed:', e.message);
32+
}
33+
};
34+
1735
// ── Get user's channels ─────────────────────────────────
1836
socket.on('get-channels', () => {
1937
const channels = getEnrichedChannels(
@@ -211,11 +229,13 @@ module.exports = function register(socket, ctx) {
211229
const _doAutoJoin = () => {
212230
const { parents, allowSet } = _resolveAutoJoinChannels();
213231
const insertMember = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)');
232+
const joinedChannelIds = [];
214233
let joinedCount = 0;
215234
const txn = db.transaction(() => {
216235
for (const parent of parents) {
217236
insertMember.run(parent.id, socket.user.id);
218237
socket.join(`channel:${parent.code}`);
238+
joinedChannelIds.push(parent.id);
219239
joinedCount++;
220240
// Sub-channels: never grant private subs via invite. When a
221241
// default-channels allowlist is set, only grant subs whose
@@ -224,11 +244,14 @@ module.exports = function register(socket, ctx) {
224244
for (const sub of subs) {
225245
insertMember.run(sub.id, socket.user.id);
226246
socket.join(`channel:${sub.code}`);
247+
joinedChannelIds.push(sub.id);
227248
joinedCount++;
228249
}
229250
}
230251
});
231252
txn();
253+
// (#5389) Apply each channel's default role after membership is settled.
254+
for (const chId of joinedChannelIds) _applyChannelDefaultRole(chId, socket.user.id);
232255
return joinedCount;
233256
};
234257

@@ -288,6 +311,9 @@ module.exports = function register(socket, ctx) {
288311
applyRoleChannelAccess(ar.id, socket.user.id, 'grant');
289312
}
290313
} catch { /* non-critical */ }
314+
315+
// (#5389) Per-channel default role auto-grant.
316+
_applyChannelDefaultRole(channel.id, socket.user.id);
291317
}
292318

293319
if (!channel.parent_channel_id) {
@@ -298,6 +324,7 @@ module.exports = function register(socket, ctx) {
298324
subs.forEach(sub => {
299325
insertSub.run(sub.id, socket.user.id);
300326
socket.join(`channel:${sub.code}`);
327+
_applyChannelDefaultRole(sub.id, socket.user.id);
301328
});
302329
}
303330

@@ -705,6 +732,64 @@ module.exports = function register(socket, ctx) {
705732
}
706733
});
707734

735+
// (#5389) Per-channel default role. Setting it auto-grants the role
736+
// (channel-scoped) to every existing member and every future joiner.
737+
// Clearing it (roleId = null/0) leaves existing grants alone — admins can
738+
// revoke from the Roles UI if they want to strip them.
739+
socket.on('set-channel-default-role', (data) => {
740+
if (!data || typeof data !== 'object') return;
741+
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
742+
return socket.emit('error-msg', 'You don\'t have permission to set a default role');
743+
}
744+
const code = typeof data.code === 'string' ? data.code.trim() : '';
745+
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
746+
const channel = db.prepare('SELECT id FROM channels WHERE code = ? AND is_dm = 0').get(code);
747+
if (!channel) return socket.emit('error-msg', 'Channel not found');
748+
749+
let roleId = null;
750+
if (data.roleId !== null && data.roleId !== undefined && data.roleId !== 0 && data.roleId !== '') {
751+
const parsed = parseInt(data.roleId, 10);
752+
if (!Number.isFinite(parsed) || parsed <= 0) {
753+
return socket.emit('error-msg', 'Invalid role');
754+
}
755+
const role = db.prepare('SELECT id, name FROM roles WHERE id = ?').get(parsed);
756+
if (!role) return socket.emit('error-msg', 'Role not found');
757+
roleId = role.id;
758+
}
759+
760+
try {
761+
db.prepare('UPDATE channels SET default_role_id = ? WHERE id = ?').run(roleId, channel.id);
762+
763+
if (roleId) {
764+
// Backfill: grant the new default to every current member of this
765+
// channel. INSERT OR IGNORE protects against duplicates if any
766+
// member already holds the role channel-scoped.
767+
const members = db.prepare('SELECT user_id FROM channel_members WHERE channel_id = ?').all(channel.id);
768+
const insertRole = db.prepare(
769+
'INSERT OR IGNORE INTO user_roles (user_id, role_id, channel_id, granted_by) VALUES (?, ?, ?, ?)'
770+
);
771+
const txn = db.transaction(() => {
772+
for (const m of members) insertRole.run(m.user_id, roleId, channel.id, socket.user.id);
773+
});
774+
txn();
775+
if (typeof applyRoleChannelAccess === 'function') {
776+
for (const m of members) {
777+
try { applyRoleChannelAccess(roleId, m.user_id, 'grant'); } catch { /* non-critical */ }
778+
}
779+
}
780+
}
781+
782+
broadcastChannelLists();
783+
io.to(`channel:${code}`).emit('channel-default-role-updated', { code, roleId });
784+
socket.emit('toast', { message: roleId ? 'Default role set' : 'Default role cleared', type: 'success' });
785+
_audit({ actor: socket.user, action: 'channel_default_role_set',
786+
target_type: 'channel', target_id: channel.id, details: { code, roleId } });
787+
} catch (err) {
788+
console.error('Set default role error:', err);
789+
socket.emit('error-msg', 'Failed to set default role');
790+
}
791+
});
792+
708793
socket.on('set-sort-alphabetical', (data) => {
709794
if (!data || typeof data !== 'object') return;
710795
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'create_channel')) return socket.emit('error-msg', 'You don\'t have permission to change sort settings');

src/socketHandlers/index.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
'use strict';
1+
'use strict';
22

33
const crypto = require('crypto');
44
const path = require('path');
@@ -214,15 +214,15 @@ function setupSocketHandlers(io, db) {
214214
c.parent_channel_id, c.position, c.is_private, c.expires_at, c.is_temp_voice,
215215
c.streams_enabled, c.music_enabled, c.media_enabled, c.slow_mode_interval, c.category, c.sort_alphabetical,
216216
c.cleanup_exempt, c.channel_type, c.voice_user_limit, c.notification_type, c.voice_enabled, c.text_enabled, c.voice_bitrate,
217-
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours
217+
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours, c.default_role_id
218218
FROM channels c WHERE c.is_dm = 0
219219
UNION
220220
SELECT c.id, c.name, c.code, c.created_by, c.topic, c.is_dm,
221221
c.code_visibility, c.code_mode, c.code_rotation_type, c.code_rotation_interval,
222222
c.parent_channel_id, c.position, c.is_private, c.expires_at, c.is_temp_voice,
223223
c.streams_enabled, c.music_enabled, c.media_enabled, c.slow_mode_interval, c.category, c.sort_alphabetical,
224224
c.cleanup_exempt, c.channel_type, c.voice_user_limit, c.notification_type, c.voice_enabled, c.text_enabled, c.voice_bitrate,
225-
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours
225+
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours, c.default_role_id
226226
FROM channels c
227227
JOIN channel_members cm ON c.id = cm.channel_id
228228
WHERE cm.user_id = ? AND c.is_dm = 1
@@ -237,7 +237,7 @@ function setupSocketHandlers(io, db) {
237237
c.parent_channel_id, c.position, c.is_private, c.expires_at, c.is_temp_voice,
238238
c.streams_enabled, c.music_enabled, c.media_enabled, c.slow_mode_interval, c.category, c.sort_alphabetical,
239239
c.cleanup_exempt, c.channel_type, c.voice_user_limit, c.notification_type, c.voice_enabled, c.text_enabled, c.voice_bitrate,
240-
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours
240+
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours, c.default_role_id
241241
FROM channels c
242242
JOIN channel_members cm ON c.id = cm.channel_id
243243
WHERE cm.user_id = ?

0 commit comments

Comments
 (0)