From a3f5130ebf177ef830c67ce0d7b800cbcdf03637 Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Sun, 1 Mar 2026 21:11:01 +0300 Subject: [PATCH 1/8] fix: protect admins/mods/owner from moderation actions (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add moderation.protectRoles config (enabled, includeAdmins, includeModerators, includeServerOwner, roleIds) - Export isProtectedTarget() from moderation.js — checks server owner, adminRoleId, moderatorRoleId, and custom roleIds - Guard executeModAction with protection check before hierarchy check; add skipProtection flag for lift-punishment commands - Add skipProtection: true to untimeout (removing punishment) - Extend configValidation.js schema with protectRoles shape - Add ModerationProtectRoles TypeScript interface to web types - Add isProtectedTarget unit tests (9 cases) in moderation.test.js - Add executeModAction protection path tests in modAction.test.js - Update moderation.js mock in all affected command tests --- config.json | 7 ++ src/api/utils/configValidation.js | 10 ++ src/commands/untimeout.js | 1 + src/modules/moderation.js | 34 ++++++ src/utils/modAction.js | 13 +++ tests/commands/ban.test.js | 1 + tests/commands/kick.test.js | 1 + tests/commands/softban.test.js | 1 + tests/commands/tempban.test.js | 1 + tests/commands/timeout.test.js | 1 + tests/commands/warn.test.js | 1 + tests/modules/moderation.test.js | 168 ++++++++++++++++++++++++++++++ tests/utils/modAction.test.js | 27 +++++ web/src/types/config.ts | 10 ++ 14 files changed, 276 insertions(+) diff --git a/config.json b/config.json index 90d791502..14ebecb34 100644 --- a/config.json +++ b/config.json @@ -104,6 +104,13 @@ "purges": null, "locks": null } + }, + "protectRoles": { + "enabled": true, + "roleIds": [], + "includeAdmins": true, + "includeModerators": true, + "includeServerOwner": true } }, "memory": { diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index 408ef0553..b7320cb37 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -110,6 +110,16 @@ export const CONFIG_SCHEMA = { }, }, }, + protectRoles: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + roleIds: { type: 'array' }, + includeAdmins: { type: 'boolean' }, + includeModerators: { type: 'boolean' }, + includeServerOwner: { type: 'boolean' }, + }, + }, }, }, triage: { diff --git a/src/commands/untimeout.js b/src/commands/untimeout.js index 50231c437..1e72ac372 100644 --- a/src/commands/untimeout.js +++ b/src/commands/untimeout.js @@ -24,6 +24,7 @@ export async function execute(interaction) { await executeModAction(interaction, { action: 'untimeout', skipDm: true, + skipProtection: true, getTarget: (inter) => { const target = inter.options.getMember('user'); if (!target) return { earlyReturn: '\u274C User is not in this server.' }; diff --git a/src/modules/moderation.js b/src/modules/moderation.js index 5a8317a4d..4deb11545 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -440,6 +440,40 @@ export function stopTempbanScheduler() { } } +/** + * Check if a target member is protected from moderation actions. + * Protected members include the server owner, admins, moderators, and any custom role IDs + * configured under `moderation.protectRoles`. + * @param {import('discord.js').GuildMember} target - Target member to check + * @param {import('discord.js').Guild} guild - Discord guild + * @param {Object} config - Bot configuration + * @returns {boolean} True if the target should not be moderated + */ +export function isProtectedTarget(target, guild, config) { + const protectRoles = config.moderation?.protectRoles; + if (!protectRoles?.enabled) return false; + + // Server owner is always protected when enabled + if (protectRoles.includeServerOwner && target.id === guild.ownerId) { + return true; + } + + const protectedRoleIds = [ + ...(protectRoles.includeAdmins && config.permissions?.adminRoleId + ? [config.permissions.adminRoleId] + : []), + ...(protectRoles.includeModerators && config.permissions?.moderatorRoleId + ? [config.permissions.moderatorRoleId] + : []), + ...(Array.isArray(protectRoles.roleIds) ? protectRoles.roleIds : []), + ].filter(Boolean); + + if (protectedRoleIds.length === 0) return false; + + const memberRoleIds = [...target.roles.cache.keys()]; + return protectedRoleIds.some((roleId) => memberRoleIds.includes(roleId)); +} + /** * Check if the moderator (and optionally the bot) can moderate a target member. * @param {import('discord.js').GuildMember} moderator - The moderator diff --git a/src/utils/modAction.js b/src/utils/modAction.js index 55f495975..e20263f1d 100644 --- a/src/utils/modAction.js +++ b/src/utils/modAction.js @@ -10,6 +10,7 @@ import { getConfig } from '../modules/config.js'; import { checkHierarchy, createCase, + isProtectedTarget, sendDmNotification, sendModLogEmbed, shouldSendDm, @@ -35,6 +36,7 @@ import { safeEditReply } from './safeSend.js'; * command — they are passed to `actionFn` but excluded from case data by the * `...extraCaseData` spread (callers must destructure them out). * @param {boolean} [opts.skipHierarchy=false] - Skip role hierarchy check + * @param {boolean} [opts.skipProtection=false] - Skip protected-role check (e.g. unban) * @param {boolean} [opts.skipDm=false] - Skip DM notification * @param {string} [opts.dmAction] - Override action name for DM (e.g. tempban uses 'ban') * @param {Function} [opts.afterCase] - async (caseData, interaction, config) => void @@ -49,6 +51,7 @@ export async function executeModAction(interaction, opts) { actionFn, extractOptions, skipHierarchy = false, + skipProtection = false, skipDm = false, dmAction, afterCase, @@ -91,6 +94,16 @@ export async function executeModAction(interaction, opts) { const { target, targetId, targetTag } = resolved; + // Protected-role check + if (!skipProtection && target) { + if (isProtectedTarget(target, interaction.guild, config)) { + return await safeEditReply( + interaction, + '\u274C Cannot moderate administrators or moderators.', + ); + } + } + // Hierarchy check if (!skipHierarchy && target) { const hierarchyError = checkHierarchy( diff --git a/tests/commands/ban.test.js b/tests/commands/ban.test.js index 4a1cb501c..5780e6d9c 100644 --- a/tests/commands/ban.test.js +++ b/tests/commands/ban.test.js @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); diff --git a/tests/commands/kick.test.js b/tests/commands/kick.test.js index 6c2f299f6..417c39a67 100644 --- a/tests/commands/kick.test.js +++ b/tests/commands/kick.test.js @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); diff --git a/tests/commands/softban.test.js b/tests/commands/softban.test.js index 6aaea1229..3f0e6bfa1 100644 --- a/tests/commands/softban.test.js +++ b/tests/commands/softban.test.js @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); diff --git a/tests/commands/tempban.test.js b/tests/commands/tempban.test.js index 9d222662e..e5ec2ced2 100644 --- a/tests/commands/tempban.test.js +++ b/tests/commands/tempban.test.js @@ -17,6 +17,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); diff --git a/tests/commands/timeout.test.js b/tests/commands/timeout.test.js index 66d3b12d2..71d80e014 100644 --- a/tests/commands/timeout.test.js +++ b/tests/commands/timeout.test.js @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); diff --git a/tests/commands/warn.test.js b/tests/commands/warn.test.js index 0a79ca9de..1c6d35f9c 100644 --- a/tests/commands/warn.test.js +++ b/tests/commands/warn.test.js @@ -14,6 +14,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkEscalation: vi.fn().mockResolvedValue(null), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); diff --git a/tests/modules/moderation.test.js b/tests/modules/moderation.test.js index c506a1267..ae930478f 100644 --- a/tests/modules/moderation.test.js +++ b/tests/modules/moderation.test.js @@ -32,6 +32,7 @@ import { checkEscalation, checkHierarchy, createCase, + isProtectedTarget, scheduleAction, sendDmNotification, sendModLogEmbed, @@ -572,6 +573,173 @@ describe('moderation module', () => { }); }); + describe('isProtectedTarget', () => { + const makeTarget = (id, roleIds = []) => ({ + id, + roles: { cache: { keys: () => roleIds } }, + }); + + const makeGuild = (ownerId) => ({ ownerId }); + + it('returns false when protectRoles is disabled', () => { + const target = makeTarget('user1', ['admin-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { protectRoles: { enabled: false } }, + permissions: { adminRoleId: 'admin-role' }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(false); + }); + + it('returns false when protectRoles config is absent', () => { + const target = makeTarget('user1'); + const guild = makeGuild('owner1'); + const config = { moderation: {} }; + expect(isProtectedTarget(target, guild, config)).toBe(false); + }); + + it('returns true for server owner when includeServerOwner is true', () => { + const target = makeTarget('owner1'); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: false, + includeModerators: false, + includeServerOwner: true, + }, + }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(true); + }); + + it('returns false for server owner when includeServerOwner is false', () => { + const target = makeTarget('owner1'); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: false, + includeModerators: false, + includeServerOwner: false, + }, + }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(false); + }); + + it('returns true for user with adminRoleId when includeAdmins is true', () => { + const target = makeTarget('user1', ['admin-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: true, + includeModerators: false, + includeServerOwner: false, + }, + }, + permissions: { adminRoleId: 'admin-role' }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(true); + }); + + it('returns false for admin role when includeAdmins is false', () => { + const target = makeTarget('user1', ['admin-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: false, + includeModerators: false, + includeServerOwner: false, + }, + }, + permissions: { adminRoleId: 'admin-role' }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(false); + }); + + it('returns true for user with moderatorRoleId when includeModerators is true', () => { + const target = makeTarget('user1', ['mod-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: false, + includeModerators: true, + includeServerOwner: false, + }, + }, + permissions: { moderatorRoleId: 'mod-role' }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(true); + }); + + it('returns true for user with a custom roleId in protectRoles.roleIds', () => { + const target = makeTarget('user1', ['custom-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: ['custom-role'], + includeAdmins: false, + includeModerators: false, + includeServerOwner: false, + }, + }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(true); + }); + + it('returns false for regular user with no protected roles', () => { + const target = makeTarget('user1', ['regular-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: true, + includeModerators: true, + includeServerOwner: true, + }, + }, + permissions: { adminRoleId: 'admin-role', moderatorRoleId: 'mod-role' }, + }; + expect(isProtectedTarget(target, guild, config)).toBe(false); + }); + + it('returns false when no protectedRoleIds resolve (no adminRoleId set) and user is non-owner', () => { + const target = makeTarget('user1', ['some-role']); + const guild = makeGuild('owner1'); + const config = { + moderation: { + protectRoles: { + enabled: true, + roleIds: [], + includeAdmins: true, + includeModerators: true, + includeServerOwner: false, + }, + }, + permissions: {}, + }; + expect(isProtectedTarget(target, guild, config)).toBe(false); + }); + }); + describe('shouldSendDm', () => { it('should return true when enabled', () => { const config = { moderation: { dmNotifications: { warn: true } } }; diff --git a/tests/utils/modAction.test.js b/tests/utils/modAction.test.js index 5916f8c8e..3818e0e97 100644 --- a/tests/utils/modAction.test.js +++ b/tests/utils/modAction.test.js @@ -5,6 +5,7 @@ vi.mock('../../src/modules/moderation.js', () => ({ sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkHierarchy: vi.fn().mockReturnValue(null), + isProtectedTarget: vi.fn().mockReturnValue(false), shouldSendDm: vi.fn().mockReturnValue(true), })); @@ -33,6 +34,7 @@ import { getConfig } from '../../src/modules/config.js'; import { checkHierarchy, createCase, + isProtectedTarget, sendDmNotification, sendModLogEmbed, shouldSendDm, @@ -261,6 +263,31 @@ describe('executeModAction', () => { expect(createCase).toHaveBeenCalled(); }); + // --------------------------------------------------------------- + // Protected-role check + // --------------------------------------------------------------- + it('should return early with error when target is protected', async () => { + const interaction = createInteraction(); + isProtectedTarget.mockReturnValueOnce(true); + + await executeModAction(interaction, defaultOpts()); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.stringContaining('Cannot moderate'), + ); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should not check protection when skipProtection is true', async () => { + const interaction = createInteraction(); + + await executeModAction(interaction, defaultOpts({ skipProtection: true })); + + expect(isProtectedTarget).not.toHaveBeenCalled(); + expect(createCase).toHaveBeenCalled(); + }); + // --------------------------------------------------------------- // 7. skipDm: true // --------------------------------------------------------------- diff --git a/web/src/types/config.ts b/web/src/types/config.ts index 5bfee1ec5..434f3ce5f 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -116,6 +116,15 @@ export interface LinkFilterConfig { blockedDomains: string[]; } +/** Protected role configuration. */ +export interface ModerationProtectRoles { + enabled: boolean; + roleIds: string[]; + includeAdmins: boolean; + includeModerators: boolean; + includeServerOwner: boolean; +} + /** Moderation configuration. */ export interface ModerationConfig { enabled: boolean; @@ -124,6 +133,7 @@ export interface ModerationConfig { dmNotifications: ModerationDmNotifications; escalation: ModerationEscalation; logging: ModerationLogging; + protectRoles?: ModerationProtectRoles; rateLimit?: RateLimitConfig; linkFilter?: LinkFilterConfig; } From 2b6262d4c7418ebf2725ba48e53ab2f9189f90f3 Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Sun, 1 Mar 2026 21:16:35 +0300 Subject: [PATCH 2/8] chore: add .gitattributes to enforce LF line endings on Windows Biome enforces LF line endings. Without this file, Git's core.autocrlf=true on Windows converts LF->CRLF on checkout causing 373+ format errors locally. * text=auto eol=lf overrides autocrlf and ensures all text files are checked out with LF regardless of OS or Git config. --- .gitattributes | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..0612a610e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Enforce LF line endings for all text files. +# Overrides core.autocrlf on Windows checkouts. +* text=auto eol=lf + +# Explicitly mark binary formats as binary (no line-ending conversion). +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.otf binary +*.zip binary +*.tar binary +*.gz binary +*.lock binary From 732deafa7c580cfab1a4488bae04ef347aaffd79 Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Sun, 1 Mar 2026 21:17:37 +0300 Subject: [PATCH 3/8] Revert "chore: add .gitattributes to enforce LF line endings on Windows" This reverts commit 2b6262d4c7418ebf2725ba48e53ab2f9189f90f3. --- .gitattributes | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 0612a610e..000000000 --- a/.gitattributes +++ /dev/null @@ -1,19 +0,0 @@ -# Enforce LF line endings for all text files. -# Overrides core.autocrlf on Windows checkouts. -* text=auto eol=lf - -# Explicitly mark binary formats as binary (no line-ending conversion). -*.png binary -*.jpg binary -*.jpeg binary -*.gif binary -*.ico binary -*.woff binary -*.woff2 binary -*.ttf binary -*.eot binary -*.otf binary -*.zip binary -*.tar binary -*.gz binary -*.lock binary From 47175ac71a28bb9f16f00639f79afb537ebc4be2 Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Sun, 1 Mar 2026 21:20:32 +0300 Subject: [PATCH 4/8] chore: add .gitattributes to enforce LF line endings Fixes 373 Biome format errors on Windows caused by core.autocrlf=true converting LF->CRLF on checkout. * text=auto eol=lf ensures all text files are checked out with LF regardless of OS or local Git config. --- .gitattributes | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..0612a610e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Enforce LF line endings for all text files. +# Overrides core.autocrlf on Windows checkouts. +* text=auto eol=lf + +# Explicitly mark binary formats as binary (no line-ending conversion). +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.otf binary +*.zip binary +*.tar binary +*.gz binary +*.lock binary From d283a738c1a7148d3d22b76a448f34917ce8eb10 Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Sun, 1 Mar 2026 21:44:39 +0300 Subject: [PATCH 5/8] fix: complete protect-roles implementation for #174 - Self-moderation: prevent mods from moderating themselves (both self-mod + protectRoles checks now share skipProtection flag so untimeout can still target self) - Audit log: warn() when protection blocks an action with action/targetId/moderatorId/guildId context - Triage: sendModerationLog skips embed when any flagged user is a protected role (admin/mod/owner); logs warn with userId/channelId - Dashboard UI: Protect Roles fieldset added to Moderation Card in config-editor.tsx (toggle + 3 checkboxes + role IDs text input) - ModerationSection component updated with onProtectRolesChange prop and matching UI - Tests: +5 tests (self-mod, warn log, skipProtection bypass, triage skip-protected, triage send-when-not-protected) --- src/modules/triage-respond.js | 21 +++++ src/utils/modAction.js | 17 +++- tests/modules/triage-respond.test.js | 69 ++++++++++++++++ tests/utils/modAction.test.js | 44 ++++++++++- .../components/dashboard/config-editor.tsx | 78 +++++++++++++++++++ .../config-sections/ModerationSection.tsx | 76 ++++++++++++++++++ 6 files changed, 302 insertions(+), 3 deletions(-) diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index 6363c2d5e..b8df80021 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -8,6 +8,7 @@ import { info, error as logError, warn } from '../logger.js'; import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter.js'; import { safeSend } from '../utils/safeSend.js'; import { splitMessage } from '../utils/splitMessage.js'; +import { isProtectedTarget } from './moderation.js'; import { resolveMessageId, sanitizeText } from './triage-filter.js'; /** Maximum characters to keep from fetched context messages. */ @@ -81,6 +82,26 @@ export async function sendModerationLog(client, classification, snapshot, channe // Find target messages from the snapshot const targets = snapshot.filter((m) => classification.targetMessageIds?.includes(m.messageId)); + // Skip moderation log if any flagged user is a protected role (admin/mod/owner) + const guild = logChannel.guild; + if (guild && targets.length > 0) { + const guildConfig = config; + for (const t of targets) { + try { + const member = await guild.members.fetch(t.userId); + if (isProtectedTarget(member, guild, guildConfig)) { + warn('Triage skipped moderation log: target is a protected role', { + userId: t.userId, + channelId, + }); + return; + } + } catch { + // Member not in guild or fetch failed — proceed with logging + } + } + } + const actionLabels = { warn: '\u26A0\uFE0F Warn', timeout: '\uD83D\uDD07 Timeout', diff --git a/src/utils/modAction.js b/src/utils/modAction.js index e20263f1d..db7afc03f 100644 --- a/src/utils/modAction.js +++ b/src/utils/modAction.js @@ -5,7 +5,7 @@ * case creation, mod log, success reply, and error handling. */ -import { debug, info, error as logError } from '../logger.js'; +import { debug, info, error as logError, warn } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { checkHierarchy, @@ -94,9 +94,22 @@ export async function executeModAction(interaction, opts) { const { target, targetId, targetTag } = resolved; - // Protected-role check + // Self-moderation and protected-role checks if (!skipProtection && target) { + // Prevent mods from moderating themselves + if (target.id === interaction.user.id) { + return await safeEditReply(interaction, '\u274C You cannot moderate yourself.'); + } + + // Block moderation of admins/mods/owner as configured if (isProtectedTarget(target, interaction.guild, config)) { + warn('Moderation blocked: target is a protected role', { + action, + targetId: target.id, + targetTag, + moderatorId: interaction.user.id, + guildId: interaction.guildId, + }); return await safeEditReply( interaction, '\u274C Cannot moderate administrators or moderators.', diff --git a/tests/modules/triage-respond.test.js b/tests/modules/triage-respond.test.js index 6cce6ca9c..5132d17ef 100644 --- a/tests/modules/triage-respond.test.js +++ b/tests/modules/triage-respond.test.js @@ -37,7 +37,12 @@ vi.mock('../../src/modules/triage-filter.js', () => ({ sanitizeText: vi.fn((text) => text), })); +vi.mock('../../src/modules/moderation.js', () => ({ + isProtectedTarget: vi.fn().mockReturnValue(false), +})); + import { warn } from '../../src/logger.js'; +import { isProtectedTarget } from '../../src/modules/moderation.js'; import { safeSend } from '../../src/utils/safeSend.js'; beforeEach(() => { @@ -260,6 +265,70 @@ describe('triage-respond', () => { sendModerationLog(mockClient, {}, [], 'channel1', config), ).resolves.not.toThrow(); }); + + it('should skip the moderation log when a target is a protected role', async () => { + isProtectedTarget.mockReturnValueOnce(true); + + const mockMember = { id: 'user1' }; + const mockGuild = { + members: { fetch: vi.fn().mockResolvedValue(mockMember) }, + }; + const mockLogChannel = { id: 'log-channel', guild: mockGuild }; + const mockClient = { + channels: { fetch: vi.fn().mockResolvedValue(mockLogChannel) }, + }; + + const classification = { + recommendedAction: 'ban', + violatedRule: 'Rule 1', + reasoning: 'Spamming', + targetMessageIds: ['msg1'], + }; + const snapshot = [ + { messageId: 'msg1', author: 'AdminUser', userId: 'user1', content: 'msg' }, + ]; + const config = { + triage: { moderationLogChannel: 'log-channel' }, + moderation: { protectRoles: { enabled: true, includeAdmins: true } }, + }; + + await sendModerationLog(mockClient, classification, snapshot, 'channel1', config); + + expect(safeSend).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('protected role'), + expect.objectContaining({ userId: 'user1' }), + ); + }); + + it('should still send moderation log when target is not protected', async () => { + isProtectedTarget.mockReturnValue(false); + + const mockMember = { id: 'user1' }; + const mockGuild = { + members: { fetch: vi.fn().mockResolvedValue(mockMember) }, + }; + const mockLogChannel = { id: 'log-channel', guild: mockGuild }; + const mockClient = { + channels: { fetch: vi.fn().mockResolvedValue(mockLogChannel) }, + }; + + const classification = { + recommendedAction: 'warn', + violatedRule: 'Rule 1', + reasoning: 'Rude message', + targetMessageIds: ['msg1'], + }; + const snapshot = [{ messageId: 'msg1', author: 'BadUser', userId: 'user1', content: 'msg' }]; + const config = { + triage: { moderationLogChannel: 'log-channel' }, + moderation: { protectRoles: { enabled: true } }, + }; + + await sendModerationLog(mockClient, classification, snapshot, 'channel1', config); + + expect(safeSend).toHaveBeenCalled(); + }); }); describe('sendResponses', () => { diff --git a/tests/utils/modAction.test.js b/tests/utils/modAction.test.js index 3818e0e97..d39876cd0 100644 --- a/tests/utils/modAction.test.js +++ b/tests/utils/modAction.test.js @@ -29,7 +29,7 @@ vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn().mockImplementation((_inter, msg) => Promise.resolve(msg)), })); -import { debug, error as logError } from '../../src/logger.js'; +import { debug, error as logError, warn } from '../../src/logger.js'; import { getConfig } from '../../src/modules/config.js'; import { checkHierarchy, @@ -279,6 +279,18 @@ describe('executeModAction', () => { expect(createCase).not.toHaveBeenCalled(); }); + it('should log a warning when protection blocks moderation', async () => { + const interaction = createInteraction(); + isProtectedTarget.mockReturnValueOnce(true); + + await executeModAction(interaction, defaultOpts()); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('protected role'), + expect.objectContaining({ targetId: 'target1' }), + ); + }); + it('should not check protection when skipProtection is true', async () => { const interaction = createInteraction(); @@ -288,6 +300,36 @@ describe('executeModAction', () => { expect(createCase).toHaveBeenCalled(); }); + it('should return early when moderator targets themselves', async () => { + // Target has same id as the moderator + const interaction = createInteraction(); + const selfTarget = { ...mockTarget, id: 'mod1' }; + const optsWithSelf = defaultOpts({ + getTarget: () => ({ target: selfTarget, targetId: 'mod1', targetTag: 'Mod#0001' }), + }); + + await executeModAction(interaction, optsWithSelf); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.stringContaining('cannot moderate yourself'), + ); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should allow self-targeting when skipProtection is true', async () => { + const interaction = createInteraction(); + const selfTarget = { ...mockTarget, id: 'mod1' }; + const optsWithSelf = defaultOpts({ + skipProtection: true, + getTarget: () => ({ target: selfTarget, targetId: 'mod1', targetTag: 'Mod#0001' }), + }); + + await executeModAction(interaction, optsWithSelf); + + expect(createCase).toHaveBeenCalled(); + }); + // --------------------------------------------------------------- // 7. skipDm: true // --------------------------------------------------------------- diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index bed31cc6c..24b2c5d04 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -573,6 +573,22 @@ export function ConfigEditor() { [updateDraftConfig], ); + const updateProtectRolesField = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + moderation: { + ...prev.moderation, + protectRoles: { ...prev.moderation?.protectRoles, [field]: value }, + }, + } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + const updatePermissionsField = useCallback( (field: string, value: unknown) => { updateDraftConfig((prev) => { @@ -1022,6 +1038,68 @@ export function ConfigEditor() { /> + + {/* Protect Roles sub-section */} +
+ Protect Roles from Moderation +
+ Enabled + updateProtectRolesField('enabled', v)} + disabled={saving} + label="Protect Roles" + /> +
+
+ Include admins + updateProtectRolesField('includeAdmins', v)} + disabled={saving} + label="Include admins" + /> +
+
+ Include moderators + updateProtectRolesField('includeModerators', v)} + disabled={saving} + label="Include moderators" + /> +
+
+ Include server owner + updateProtectRolesField('includeServerOwner', v)} + disabled={saving} + label="Include server owner" + /> +
+ +
)} diff --git a/web/src/components/dashboard/config-sections/ModerationSection.tsx b/web/src/components/dashboard/config-sections/ModerationSection.tsx index 58abc3482..98e1fc392 100644 --- a/web/src/components/dashboard/config-sections/ModerationSection.tsx +++ b/web/src/components/dashboard/config-sections/ModerationSection.tsx @@ -13,6 +13,7 @@ interface ModerationSectionProps { onFieldChange: (field: string, value: unknown) => void; onDmNotificationChange: (action: string, value: boolean) => void; onEscalationChange: (enabled: boolean) => void; + onProtectRolesChange: (field: string, value: unknown) => void; } export function ModerationSection({ @@ -22,6 +23,7 @@ export function ModerationSection({ onFieldChange, onDmNotificationChange, onEscalationChange, + onProtectRolesChange, }: ModerationSectionProps) { if (!draftConfig.moderation) return null; @@ -96,6 +98,80 @@ export function ModerationSection({ aria-label="Toggle escalation" /> + + {/* Protect Roles sub-section */} +
+ Protect Roles from Moderation +
+ + onProtectRolesChange('enabled', v)} + disabled={saving} + aria-label="Toggle protect roles" + /> +
+
+ + onProtectRolesChange('includeAdmins', v)} + disabled={saving} + aria-label="Include admins" + /> +
+
+ + onProtectRolesChange('includeModerators', v)} + disabled={saving} + aria-label="Include moderators" + /> +
+
+ + onProtectRolesChange('includeServerOwner', v)} + disabled={saving} + aria-label="Include server owner" + /> +
+
+ + + onProtectRolesChange( + 'roleIds', + e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + disabled={saving} + placeholder="Role ID 1, Role ID 2" + /> +
+
); From b5452be1103e05682e2bbcae84990fb05bd3bf15 Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Mon, 2 Mar 2026 00:32:39 +0300 Subject: [PATCH 6/8] fix: address code review feedback on protect-roles (#181) --- .gitattributes | 1 - README.md | 5 +++++ src/modules/moderation.js | 19 +++++++++++++++++-- src/modules/triage-respond.js | 6 ++++-- src/utils/modAction.js | 2 +- tests/commands/kick.test.js | 17 ++++++++++++++++- .../components/dashboard/config-editor.tsx | 10 +++++++--- .../config-sections/ModerationSection.tsx | 17 ++++++++++++++--- 8 files changed, 64 insertions(+), 13 deletions(-) diff --git a/.gitattributes b/.gitattributes index 0612a610e..f6015ae87 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,4 +16,3 @@ *.zip binary *.tar binary *.gz binary -*.lock binary diff --git a/README.md b/README.md index 699ae14db..b04fd42ca 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,11 @@ All configuration lives in `config.json` and can be updated at runtime via the ` | `logging.channels.timeouts` | string | Channel for timeout events | | `logging.channels.purges` | string | Channel for purge events | | `logging.channels.locks` | string | Channel for lock/unlock events | +| `protectRoles.enabled` | boolean | Enable role protection (prevents moderating admins/mods/owner) | +| `protectRoles.includeServerOwner` | boolean | Include server owner in protection | +| `protectRoles.includeAdmins` | boolean | Include admin role in protection | +| `protectRoles.includeModerators` | boolean | Include moderator role in protection | +| `protectRoles.roleIds` | string[] | Additional role IDs to protect from moderation | **Escalation thresholds** are objects with: `warns` (count), `withinDays` (window), `action` ("timeout" or "ban"), `duration` (for timeout, e.g. "1h"). diff --git a/src/modules/moderation.js b/src/modules/moderation.js index 4deb11545..55737034f 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -450,8 +450,23 @@ export function stopTempbanScheduler() { * @returns {boolean} True if the target should not be moderated */ export function isProtectedTarget(target, guild, config) { - const protectRoles = config.moderation?.protectRoles; - if (!protectRoles?.enabled) return false; + /** + * When the protectRoles block is missing from persisted configuration, + * fall back to the intended defaults: protection enabled, include owner, + * admins, and moderators (matches config.json defaults and web UI defaults). + */ + const defaultProtectRoles = { + enabled: true, + includeAdmins: true, + includeModerators: true, + includeServerOwner: true, + roleIds: [], + }; + + const protectRoles = config.moderation?.protectRoles ?? defaultProtectRoles; + if (!protectRoles.enabled) { + return false; + } // Server owner is always protected when enabled if (protectRoles.includeServerOwner && target.id === guild.ownerId) { diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index b8df80021..c169e27a9 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -85,11 +85,13 @@ export async function sendModerationLog(client, classification, snapshot, channe // Skip moderation log if any flagged user is a protected role (admin/mod/owner) const guild = logChannel.guild; if (guild && targets.length > 0) { - const guildConfig = config; + const seenUserIds = new Set(); for (const t of targets) { + if (seenUserIds.has(t.userId)) continue; + seenUserIds.add(t.userId); try { const member = await guild.members.fetch(t.userId); - if (isProtectedTarget(member, guild, guildConfig)) { + if (isProtectedTarget(member, guild, config)) { warn('Triage skipped moderation log: target is a protected role', { userId: t.userId, channelId, diff --git a/src/utils/modAction.js b/src/utils/modAction.js index db7afc03f..a6a137d17 100644 --- a/src/utils/modAction.js +++ b/src/utils/modAction.js @@ -112,7 +112,7 @@ export async function executeModAction(interaction, opts) { }); return await safeEditReply( interaction, - '\u274C Cannot moderate administrators or moderators.', + '\u274C Cannot moderate protected users (server owner, admins, or moderators).', ); } } diff --git a/tests/commands/kick.test.js b/tests/commands/kick.test.js index 417c39a67..7ce43a7eb 100644 --- a/tests/commands/kick.test.js +++ b/tests/commands/kick.test.js @@ -27,7 +27,12 @@ vi.mock('../../src/modules/config.js', () => ({ vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); import { adminOnly, data, execute } from '../../src/commands/kick.js'; -import { checkHierarchy, createCase, sendDmNotification } from '../../src/modules/moderation.js'; +import { + checkHierarchy, + createCase, + isProtectedTarget, + sendDmNotification, +} from '../../src/modules/moderation.js'; describe('kick command', () => { afterEach(() => { @@ -76,6 +81,16 @@ describe('kick command', () => { expect(adminOnly).toBe(true); }); + it('should reject when target is a protected role', async () => { + isProtectedTarget.mockReturnValueOnce(true); + const { interaction, mockMember } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('protected')); + expect(mockMember.kick).not.toHaveBeenCalled(); + }); + it('should kick a user successfully', async () => { const { interaction, mockMember } = createInteraction(); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 24b2c5d04..38e3085a8 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -130,6 +130,7 @@ export function ConfigEditor() { /** Raw textarea strings — kept separate so partial input isn't stripped on every keystroke. */ const [roleMenuRaw, setRoleMenuRaw] = useState(''); const [dmStepsRaw, setDmStepsRaw] = useState(''); + const [protectRoleIdsRaw, setProtectRoleIdsRaw] = useState(''); const abortRef = useRef(null); @@ -201,6 +202,7 @@ export function ConfigEditor() { setDraftConfig(structuredClone(data)); setRoleMenuRaw(stringifyRoleMenuOptions(data.welcome?.roleMenu?.options ?? [])); setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); + setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', ')); } catch (err) { if ((err as Error).name === 'AbortError') return; const msg = (err as Error).message || 'Failed to load config'; @@ -373,6 +375,7 @@ export function ConfigEditor() { setDraftConfig(structuredClone(savedConfig)); setRoleMenuRaw(stringifyRoleMenuOptions(savedConfig.welcome?.roleMenu?.options ?? [])); setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n')); + setProtectRoleIdsRaw((savedConfig.moderation?.protectRoles?.roleIds ?? []).join(', ')); toast.success('Changes discarded.'); }, [savedConfig]); @@ -1084,11 +1087,12 @@ export function ConfigEditor() { + value={protectRoleIdsRaw} + onChange={(e) => setProtectRoleIdsRaw(e.target.value)} + onBlur={() => updateProtectRolesField( 'roleIds', - e.target.value + protectRoleIdsRaw .split(',') .map((s) => s.trim()) .filter(Boolean), diff --git a/web/src/components/dashboard/config-sections/ModerationSection.tsx b/web/src/components/dashboard/config-sections/ModerationSection.tsx index 98e1fc392..1a352dc7e 100644 --- a/web/src/components/dashboard/config-sections/ModerationSection.tsx +++ b/web/src/components/dashboard/config-sections/ModerationSection.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useEffect, useState } from 'react'; + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -25,6 +27,14 @@ export function ModerationSection({ onEscalationChange, onProtectRolesChange, }: ModerationSectionProps) { + const [roleIdsRaw, setRoleIdsRaw] = useState( + (draftConfig.moderation?.protectRoles?.roleIds ?? []).join(', '), + ); + + useEffect(() => { + setRoleIdsRaw((draftConfig.moderation?.protectRoles?.roleIds ?? []).join(', ')); + }, [draftConfig.moderation?.protectRoles?.roleIds]); + if (!draftConfig.moderation) return null; return ( @@ -157,11 +167,12 @@ export function ModerationSection({ + value={roleIdsRaw} + onChange={(e) => setRoleIdsRaw(e.target.value)} + onBlur={() => onProtectRolesChange( 'roleIds', - e.target.value + roleIdsRaw .split(',') .map((s) => s.trim()) .filter(Boolean), From e1d6b961d1b6552984bdabf0476c0225f4a27e1a Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Mon, 2 Mar 2026 00:46:32 +0300 Subject: [PATCH 7/8] fix: separate self-mod from skipProtection, simplify error message --- src/utils/modAction.js | 18 +++++++----------- tests/utils/modAction.test.js | 8 ++++++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/utils/modAction.js b/src/utils/modAction.js index a6a137d17..27b567d6b 100644 --- a/src/utils/modAction.js +++ b/src/utils/modAction.js @@ -94,14 +94,13 @@ export async function executeModAction(interaction, opts) { const { target, targetId, targetTag } = resolved; - // Self-moderation and protected-role checks - if (!skipProtection && target) { - // Prevent mods from moderating themselves - if (target.id === interaction.user.id) { - return await safeEditReply(interaction, '\u274C You cannot moderate yourself.'); - } + // Self-moderation is always blocked, even when skipProtection is true + if (target && target.id === interaction.user.id) { + return await safeEditReply(interaction, '\u274C You cannot moderate yourself.'); + } - // Block moderation of admins/mods/owner as configured + // Protected-role check (skipped when skipProtection is true) + if (!skipProtection && target) { if (isProtectedTarget(target, interaction.guild, config)) { warn('Moderation blocked: target is a protected role', { action, @@ -110,10 +109,7 @@ export async function executeModAction(interaction, opts) { moderatorId: interaction.user.id, guildId: interaction.guildId, }); - return await safeEditReply( - interaction, - '\u274C Cannot moderate protected users (server owner, admins, or moderators).', - ); + return await safeEditReply(interaction, '\u274C Cannot moderate a protected user.'); } } diff --git a/tests/utils/modAction.test.js b/tests/utils/modAction.test.js index d39876cd0..40e2d89b5 100644 --- a/tests/utils/modAction.test.js +++ b/tests/utils/modAction.test.js @@ -317,7 +317,7 @@ describe('executeModAction', () => { expect(createCase).not.toHaveBeenCalled(); }); - it('should allow self-targeting when skipProtection is true', async () => { + it('should block self-targeting even when skipProtection is true', async () => { const interaction = createInteraction(); const selfTarget = { ...mockTarget, id: 'mod1' }; const optsWithSelf = defaultOpts({ @@ -327,7 +327,11 @@ describe('executeModAction', () => { await executeModAction(interaction, optsWithSelf); - expect(createCase).toHaveBeenCalled(); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.stringContaining('cannot moderate yourself'), + ); + expect(createCase).not.toHaveBeenCalled(); }); // --------------------------------------------------------------- From 244da9ceb589dacedf6cf95fce2e32d2a1a996fb Mon Sep 17 00:00:00 2001 From: TheCoderBuilder-dev Date: Mon, 2 Mar 2026 01:00:49 +0300 Subject: [PATCH 8/8] fix: deep-merge protectRoles defaults, remove dead code from ModerationSection --- src/modules/moderation.js | 4 +- .../components/dashboard/config-editor.tsx | 9 +- .../config-sections/ModerationSection.tsx | 87 ------------------- 3 files changed, 11 insertions(+), 89 deletions(-) diff --git a/src/modules/moderation.js b/src/modules/moderation.js index 55737034f..082ff7994 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -463,7 +463,9 @@ export function isProtectedTarget(target, guild, config) { roleIds: [], }; - const protectRoles = config.moderation?.protectRoles ?? defaultProtectRoles; + // Deep-merge defaults so a partial persisted object (e.g. only roleIds set) + // never leaves enabled/include* as undefined/falsy. + const protectRoles = { ...defaultProtectRoles, ...config.moderation?.protectRoles }; if (!protectRoles.enabled) { return false; } diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 38e3085a8..d3b0036ad 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -580,11 +580,18 @@ export function ConfigEditor() { (field: string, value: unknown) => { updateDraftConfig((prev) => { if (!prev) return prev; + const existingProtectRoles = prev.moderation?.protectRoles ?? { + enabled: true, + includeAdmins: true, + includeModerators: true, + includeServerOwner: true, + roleIds: [], + }; return { ...prev, moderation: { ...prev.moderation, - protectRoles: { ...prev.moderation?.protectRoles, [field]: value }, + protectRoles: { ...existingProtectRoles, [field]: value }, }, } as GuildConfig; }); diff --git a/web/src/components/dashboard/config-sections/ModerationSection.tsx b/web/src/components/dashboard/config-sections/ModerationSection.tsx index 1a352dc7e..58abc3482 100644 --- a/web/src/components/dashboard/config-sections/ModerationSection.tsx +++ b/web/src/components/dashboard/config-sections/ModerationSection.tsx @@ -1,7 +1,5 @@ 'use client'; -import { useEffect, useState } from 'react'; - import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -15,7 +13,6 @@ interface ModerationSectionProps { onFieldChange: (field: string, value: unknown) => void; onDmNotificationChange: (action: string, value: boolean) => void; onEscalationChange: (enabled: boolean) => void; - onProtectRolesChange: (field: string, value: unknown) => void; } export function ModerationSection({ @@ -25,16 +22,7 @@ export function ModerationSection({ onFieldChange, onDmNotificationChange, onEscalationChange, - onProtectRolesChange, }: ModerationSectionProps) { - const [roleIdsRaw, setRoleIdsRaw] = useState( - (draftConfig.moderation?.protectRoles?.roleIds ?? []).join(', '), - ); - - useEffect(() => { - setRoleIdsRaw((draftConfig.moderation?.protectRoles?.roleIds ?? []).join(', ')); - }, [draftConfig.moderation?.protectRoles?.roleIds]); - if (!draftConfig.moderation) return null; return ( @@ -108,81 +96,6 @@ export function ModerationSection({ aria-label="Toggle escalation" /> - - {/* Protect Roles sub-section */} -
- Protect Roles from Moderation -
- - onProtectRolesChange('enabled', v)} - disabled={saving} - aria-label="Toggle protect roles" - /> -
-
- - onProtectRolesChange('includeAdmins', v)} - disabled={saving} - aria-label="Include admins" - /> -
-
- - onProtectRolesChange('includeModerators', v)} - disabled={saving} - aria-label="Include moderators" - /> -
-
- - onProtectRolesChange('includeServerOwner', v)} - disabled={saving} - aria-label="Include server owner" - /> -
-
- - setRoleIdsRaw(e.target.value)} - onBlur={() => - onProtectRolesChange( - 'roleIds', - roleIdsRaw - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ) - } - disabled={saving} - placeholder="Role ID 1, Role ID 2" - /> -
-
);