From 908e1b8bd49d9be90d1798e937bdcf26a4ab3373 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 15:28:01 +0200 Subject: [PATCH] fix(api): correct guild permission and mfa checks (#2499) --- .../services/BaseChannelAuthService.ts | 14 ++- .../tests/MessageSendPermissions.test.ts | 31 ++++++- .../controllers/GuildDiscoveryController.ts | 41 ++------- .../GuildMemberSearchController.ts | 9 +- .../member/GuildMemberOperationsService.ts | 7 +- .../DiscoveryApplicationLifecycle.test.ts | 61 ++++++++++++- .../guild/tests/GuildMemberManagement.test.ts | 60 +++++++++++++ .../tests/GuildMemberSearchEndpoint.test.ts | 70 ++++++++++++++- .../items/VoiceParticipantMenuData.tsx | 88 ++++++++++--------- .../VoicePermissionStateMachine.test.ts | 1 + .../engine/VoicePermissionStateMachine.ts | 4 +- .../voice/utils/VoicePermissionUtils.ts | 3 + 12 files changed, 303 insertions(+), 86 deletions(-) diff --git a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts index c336de430..77e686dc7 100644 --- a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts +++ b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts @@ -174,7 +174,7 @@ export abstract class BaseChannelAuthService { const guildId = channel.guildId!; const [authContextResult, guildMemberResult] = await Promise.all([ this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}), - this.gatewayService.getGuildMember({guildId, userId}), + this.fetchGuildMemberOrThrow({guildId, userId}), ]); if (!authContextResult) { this.throwGuildAccessError(); @@ -285,6 +285,18 @@ export abstract class BaseChannelAuthService { } } + private async fetchGuildMemberOrThrow(params: {guildId: GuildID; userId: UserID}): Promise<{ + success: boolean; + memberData?: GuildMemberResponse; + }> { + try { + return await this.gatewayService.getGuildMember(params); + } catch (error) { + await this.handleGuildAccessError(error, params.guildId); + throw error; + } + } + private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise { if (error instanceof UnknownGuildError) { if (await this.guildExists(guildId)) { diff --git a/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts b/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts index 75370ccf0..173c3815d 100644 --- a/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts +++ b/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts @@ -2,6 +2,7 @@ import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants'; +import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; import {createTestAccount} from '../../auth/tests/AuthTestUtils'; @@ -9,7 +10,7 @@ import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils'; import {ensureSessionStarted} from '../../message/tests/MessageTestUtils'; import {getGatewayService} from '../../middleware/ServiceRegistry'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; -import {HTTP_STATUS} from '../../test/TestConstants'; +import {HTTP_STATUS, TEST_TIMEOUTS, wait} from '../../test/TestConstants'; import {createBuilder} from '../../test/TestRequestBuilder'; import {ChannelDataRepository} from '../repositories/ChannelDataRepository'; import { @@ -169,6 +170,34 @@ describe('Message send permissions', () => { } }); + it('returns ACCESS_DENIED not UNKNOWN_GUILD when the Gateway has dropped a guild whose record survives', async () => { + const {owner, systemChannel} = await setupTestGuildWithMembers(harness, 0); + await ensureSessionStarted(harness, owner.token); + const gatewayService = getGatewayService(); + + for (const memberSettlesFirst of [true, false]) { + const getGuildAuthContext = vi.spyOn(gatewayService, 'getGuildAuthContext').mockImplementation(async () => { + if (memberSettlesFirst) await wait(TEST_TIMEOUTS.IMMEDIATE); + throw new UnknownGuildError(); + }); + const getGuildMember = vi.spyOn(gatewayService, 'getGuildMember').mockImplementation(async () => { + if (!memberSettlesFirst) await wait(TEST_TIMEOUTS.IMMEDIATE); + throw new UnknownGuildError(); + }); + + try { + await createBuilder(harness, owner.token) + .post(`/channels/${systemChannel.id}/messages`) + .body({content: 'the gateway forgot this guild'}) + .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') + .execute(); + } finally { + getGuildAuthContext.mockRestore(); + getGuildMember.mockRestore(); + } + } + }); + it('allows bot message content up to 4000 characters', async () => { const {owner, guild, systemChannel} = await setupTestGuildWithMembers(harness, 0); const botAccount = await createTestBotAccount(harness); diff --git a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts index b3e0ed6f4..09bcee403 100644 --- a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts @@ -3,7 +3,6 @@ import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants'; import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; -import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError'; import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError'; import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError'; @@ -160,14 +159,8 @@ export function GuildDiscoveryController(app: HonoApp) { const {guild_id} = ctx.req.valid('param'); const guildId = createGuildID(guild_id); const data = ctx.req.valid('json'); - const hasPermission = await ctx.get('gatewayService').checkPermission({ - guildId, - userId: user.id, - permission: Permissions.MANAGE_GUILD, - }); - if (!hasPermission) { - throw new MissingPermissionsError(); - } + const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId}); + await checkPermission(Permissions.MANAGE_GUILD); const row = await ctx.get('discoveryService').apply({ guildId, userId: user.id, @@ -201,14 +194,8 @@ export function GuildDiscoveryController(app: HonoApp) { const {guild_id} = ctx.req.valid('param'); const guildId = createGuildID(guild_id); const data = ctx.req.valid('json'); - const hasPermission = await ctx.get('gatewayService').checkPermission({ - guildId, - userId: user.id, - permission: Permissions.MANAGE_GUILD, - }); - if (!hasPermission) { - throw new MissingPermissionsError(); - } + const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId}); + await checkPermission(Permissions.MANAGE_GUILD); const row = await ctx.get('discoveryService').editApplication({ guildId, userId: user.id, @@ -237,14 +224,8 @@ export function GuildDiscoveryController(app: HonoApp) { const user = ctx.get('user'); const {guild_id} = ctx.req.valid('param'); const guildId = createGuildID(guild_id); - const hasPermission = await ctx.get('gatewayService').checkPermission({ - guildId, - userId: user.id, - permission: Permissions.MANAGE_GUILD, - }); - if (!hasPermission) { - throw new MissingPermissionsError(); - } + const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId}); + await checkPermission(Permissions.MANAGE_GUILD); await ctx.get('discoveryService').withdraw({guildId, userId: user.id}); return ctx.body(null, 204); }, @@ -267,14 +248,8 @@ export function GuildDiscoveryController(app: HonoApp) { const user = ctx.get('user'); const {guild_id} = ctx.req.valid('param'); const guildId = createGuildID(guild_id); - const hasPermission = await ctx.get('gatewayService').checkPermission({ - guildId, - userId: user.id, - permission: Permissions.MANAGE_GUILD, - }); - if (!hasPermission) { - throw new MissingPermissionsError(); - } + const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId}); + await checkPermission(Permissions.MANAGE_GUILD); const discoveryService = ctx.get('discoveryService'); const row = await discoveryService.getStatus(guildId); const eligibility = await discoveryService.getEligibility(guildId); diff --git a/fluxer_api/src/api/guild/controllers/GuildMemberSearchController.ts b/fluxer_api/src/api/guild/controllers/GuildMemberSearchController.ts index 0430cc0e0..b3875c6ce 100644 --- a/fluxer_api/src/api/guild/controllers/GuildMemberSearchController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildMemberSearchController.ts @@ -20,6 +20,7 @@ import {guildMembersNeedReindexing} from '../../search/GuildMemberIndexingUtils' import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; import {GuildRepository} from '../repositories/GuildRepository'; +import {createGuildMfaEnforcer} from '../services/GuildMfaEnforcement'; const MEMBERS_PAGE_PERMISSIONS = Permissions.MANAGE_GUILD | @@ -81,11 +82,17 @@ export function GuildMemberSearchController(app: HonoApp) { const guildId = createGuildID(ctx.req.valid('param').guild_id); const guildIdString = guildId.toString(); const body = ctx.req.valid('json'); - const {getMyPermissions} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId}); + const {getMyPermissions, guildData} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId}); const permissions = await getMyPermissions(); if ((permissions & MEMBERS_PAGE_PERMISSIONS) === 0n) { throw new MissingPermissionsError(); } + const enforceGuildMfa = await createGuildMfaEnforcer({ + userRepository: ctx.get('userRepository'), + guildData, + userId, + }); + enforceGuildMfa(permissions & MEMBERS_PAGE_PERMISSIONS); const canViewInvites = (permissions & Permissions.MANAGE_GUILD) !== 0n; const guild = await guildRepository.findUnique(guildId); if (!guild) { diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts index 0c670a782..da6f30c76 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts @@ -914,11 +914,16 @@ export class GuildMemberOperationsService { if (userId !== targetId) { await checkTargetMember(targetId); } - if (data.mute !== undefined || data.deaf !== undefined) { + if (data.mute !== undefined) { if (!(await hasPermission(Permissions.MUTE_MEMBERS))) { throw new MissingPermissionsError(); } } + if (data.deaf !== undefined) { + if (!(await hasPermission(Permissions.DEAFEN_MEMBERS))) { + throw new MissingPermissionsError(); + } + } if (data.channel_id !== undefined) { if (!(await hasPermission(Permissions.MOVE_MEMBERS))) { throw new MissingPermissionsError(); diff --git a/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts b/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts index 8cf3e322a..d671a8088 100644 --- a/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts @@ -1,17 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants'; -import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; +import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants'; import type { DiscoveryApplicationResponse, DiscoveryStatusResponse, } from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; -import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {createTestAccount, setUserACLs, type TestAccount, totpCodeNow} from '../../auth/tests/AuthTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; -import {createBuilder} from '../../test/TestRequestBuilder'; -import {createGuild, getGuild} from './GuildTestUtils'; +import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {addMemberRole, createGuild, createRole, getGuild, setupTestGuildWithMembers} from './GuildTestUtils'; + +const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise { await createBuilder(harness, '') @@ -67,6 +71,33 @@ async function adminReject( .execute(); } +async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise { + await createBuilder(harness, account.token) + .post('/users/@me/mfa/totp/enable') + .body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: account.password}) + .expect(HTTP_STATUS.OK) + .execute(); +} + +async function loginWithTotp(harness: ApiTestHarness, account: TestAccount): Promise { + const loginResp = await createBuilderWithoutAuth<{ + mfa: true; + ticket: string; + }>(harness) + .post('/auth/login') + .body({email: account.email, password: account.password}) + .expect(HTTP_STATUS.OK) + .execute(); + const mfaResp = await createBuilderWithoutAuth<{ + token: string; + }>(harness) + .post('/auth/login/mfa/totp') + .body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket}) + .expect(HTTP_STATUS.OK) + .execute(); + return {...account, token: mfaResp.token}; +} + async function createAdminAccount(harness: ApiTestHarness) { const admin = await createTestAccount(harness); return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']); @@ -251,6 +282,28 @@ describe('Discovery Application Lifecycle', () => { const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc'); expect(application.description).toBe('Short desc'); }); + test('rejects apply with TWO_FACTOR_REQUIRED for a non-owner without MFA in an elevated guild', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1); + const member = members[0]!; + const manageGuildRole = await createRole(harness, owner.token, guild.id, { + name: 'Manage Guild', + permissions: Permissions.MANAGE_GUILD.toString(), + }); + await addMemberRole(harness, owner.token, guild.id, member.userId, manageGuildRole.id); + await enableTotp(harness, owner); + const elevatedOwner = await loginWithTotp(harness, owner); + await createBuilder(harness, elevatedOwner.token) + .patch(`/guilds/${guild.id}`) + .body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)}) + .expect(HTTP_STATUS.OK) + .execute(); + await setGuildMemberCount(harness, guild.id, 1); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/discovery`) + .body({description: 'Moderator without an authenticator', category_type: DiscoveryCategories.GAMING}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.TWO_FACTOR_REQUIRED) + .execute(); + }); test('should allow applying with maximum description length', async () => { const owner = await createTestAccount(harness); const guild = await createGuild(harness, owner.token, 'Max Desc Guild'); diff --git a/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts b/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts index 24f0ca9fc..2a29b6982 100644 --- a/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts @@ -530,6 +530,66 @@ describe('Guild Member Management', () => { .execute(); }); }); + describe('Voice Mute and Deafen Permission Checks', () => { + test('should not allow member with MUTE_MEMBERS only to deafen a member', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2); + const [moderator, target] = members; + const modRole = await createRole(harness, owner.token, guild.id, { + name: 'Muter', + permissions: Permissions.MUTE_MEMBERS.toString(), + }); + await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id); + await createBuilder(harness, moderator.token) + .patch(`/guilds/${guild.id}/members/${target.userId}`) + .body({deaf: true}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + test('should not allow member with DEAFEN_MEMBERS only to mute a member', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2); + const [moderator, target] = members; + const modRole = await createRole(harness, owner.token, guild.id, { + name: 'Deafener', + permissions: Permissions.DEAFEN_MEMBERS.toString(), + }); + await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id); + await createBuilder(harness, moderator.token) + .patch(`/guilds/${guild.id}/members/${target.userId}`) + .body({mute: true}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + test('should allow member with DEAFEN_MEMBERS only to deafen a member', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2); + const [moderator, target] = members; + const modRole = await createRole(harness, owner.token, guild.id, { + name: 'Deafener', + permissions: Permissions.DEAFEN_MEMBERS.toString(), + }); + await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id); + const updatedMember = await createBuilder(harness, moderator.token) + .patch(`/guilds/${guild.id}/members/${target.userId}`) + .body({deaf: true}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(updatedMember.deaf).toBe(true); + }); + test('should allow member with MUTE_MEMBERS only to mute a member', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2); + const [moderator, target] = members; + const modRole = await createRole(harness, owner.token, guild.id, { + name: 'Muter', + permissions: Permissions.MUTE_MEMBERS.toString(), + }); + await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id); + const updatedMember = await createBuilder(harness, moderator.token) + .patch(`/guilds/${guild.id}/members/${target.userId}`) + .body({mute: true}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(updatedMember.mute).toBe(true); + }); + }); describe('Member Avatar Upload Validation', () => { test('should reject member avatar upload without premium', async () => { const {members, guild} = await setupTestGuildWithMembers(harness, 1); diff --git a/fluxer_api/src/api/search/tests/GuildMemberSearchEndpoint.test.ts b/fluxer_api/src/api/search/tests/GuildMemberSearchEndpoint.test.ts index 428693594..1563b59cc 100644 --- a/fluxer_api/src/api/search/tests/GuildMemberSearchEndpoint.test.ts +++ b/fluxer_api/src/api/search/tests/GuildMemberSearchEndpoint.test.ts @@ -1,9 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {GuildMFALevel, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; import type {GuildMemberSearchResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSearchSchemas'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; -import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {createTestAccount, type TestAccount, totpCodeNow} from '../../auth/tests/AuthTestUtils'; import {createGuildID, createUserID} from '../../BrandedTypes'; import {GuildRepository} from '../../guild/repositories/GuildRepository'; import { @@ -17,7 +18,37 @@ import { } from '../../guild/tests/GuildTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS, wait} from '../../test/TestConstants'; -import {createBuilder} from '../../test/TestRequestBuilder'; +import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; + +const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; + +async function elevateGuildMfaLevel(harness: ApiTestHarness, owner: TestAccount, guildId: string): Promise { + await createBuilder(harness, owner.token) + .post('/users/@me/mfa/totp/enable') + .body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: owner.password}) + .expect(HTTP_STATUS.OK) + .execute(); + const loginResp = await createBuilderWithoutAuth<{ + mfa: true; + ticket: string; + }>(harness) + .post('/auth/login') + .body({email: owner.email, password: owner.password}) + .expect(HTTP_STATUS.OK) + .execute(); + const mfaResp = await createBuilderWithoutAuth<{ + token: string; + }>(harness) + .post('/auth/login/mfa/totp') + .body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket}) + .expect(HTTP_STATUS.OK) + .execute(); + await createBuilder(harness, mfaResp.token) + .patch(`/guilds/${guildId}`) + .body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)}) + .expect(HTTP_STATUS.OK) + .execute(); +} async function indexGuildMembers(harness: ApiTestHarness, guildId: string): Promise { await createBuilder<{ @@ -325,6 +356,39 @@ describe('Guild Member Search Endpoint', () => { .execute(); expect(result.members.length).toBeGreaterThan(0); }); + test('rejects an elevated-permission holder without MFA in an elevated guild (400)', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1); + const moderator = members[0]!; + const banRole = await createRole(harness, owner.token, guild.id, { + name: 'Ban Members', + permissions: Permissions.BAN_MEMBERS.toString(), + }); + await updateMember(harness, owner.token, guild.id, moderator.userId, {roles: [banRole.id]}); + await indexGuildMembers(harness, guild.id); + await elevateGuildMfaLevel(harness, owner, guild.id); + await createBuilder(harness, moderator.token) + .post(`/guilds/${guild.id}/members-search`) + .body({}) + .expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED') + .execute(); + }); + test('allows a MANAGE_NICKNAMES holder without MFA in an elevated guild', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1); + const nicknameManager = members[0]!; + const nicknameRole = await createRole(harness, owner.token, guild.id, { + name: 'Manage Nicknames', + permissions: Permissions.MANAGE_NICKNAMES.toString(), + }); + await updateMember(harness, owner.token, guild.id, nicknameManager.userId, {roles: [nicknameRole.id]}); + await indexGuildMembers(harness, guild.id); + await elevateGuildMfaLevel(harness, owner, guild.id); + const result = await createBuilder(harness, nicknameManager.token) + .post(`/guilds/${guild.id}/members-search`) + .body({}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.members.length).toBeGreaterThan(0); + }); test('returns 404 for non-guild-member', async () => { const {guild} = await setupTestGuildWithMembers(harness, 1); const outsider = await createTestAccount(harness); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx index d2ba26a5a..e82252ad2 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx @@ -246,6 +246,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt const isBlocked = relationshipType === RelationshipTypes.BLOCKED; const hasActiveDirectCall = hasActiveDirectCallWithUser(user.id); const canMuteMembers = guildId ? Permission.can(Permissions.MUTE_MEMBERS, {guildId}) : false; + const canDeafenMembers = guildId ? Permission.can(Permissions.DEAFEN_MEMBERS, {guildId}) : false; const canMoveMembers = guildId ? Permission.can(Permissions.MOVE_MEMBERS, {guildId}) : false; const canKickMembers = guildId ? Permission.can(Permissions.KICK_MEMBERS, {guildId}) : false; const canBanMembers = guildId ? Permission.can(Permissions.BAN_MEMBERS, {guildId}) : false; @@ -1181,49 +1182,53 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt } if (guildId && member) { const nextModerationActions: Array = []; - if (canMuteMembers && (isCurrentUser || canManageTarget(user.id))) { + if ((canMuteMembers || canDeafenMembers) && (isCurrentUser || canManageTarget(user.id))) { const isGuildMuted = memberMute; const isGuildDeafened = memberDeaf; - nextModerationActions.push({ - icon: ( - - ), - label: i18n._(VOICE_COMMUNITY_MUTE_DESCRIPTOR), - checked: isGuildMuted, - onChange: async (checked: boolean) => { - try { - await GuildMemberCommands.update(guildId, user.id, {mute: checked}); - if (checked) SoundCommands.playSound(SoundType.Mute); - else SoundCommands.playSound(SoundType.Unmute); - } catch (error) { - logger.error('Failed to update community mute:', error); - showVoiceMemberModerationFailedModal(error, VOICE_COMMUNITY_MUTE_DESCRIPTOR); - } - }, - }); - nextModerationActions.push({ - icon: ( - - ), - label: i18n._(VOICE_COMMUNITY_DEAFEN_DESCRIPTOR), - checked: isGuildDeafened, - onChange: async (checked: boolean) => { - try { - await GuildMemberCommands.update(guildId, user.id, {deaf: checked}); - if (checked) SoundCommands.playSound(SoundType.Deaf); - else SoundCommands.playSound(SoundType.Undeaf); - } catch (error) { - logger.error('Failed to update community deafen:', error); - showVoiceMemberModerationFailedModal(error, VOICE_COMMUNITY_DEAFEN_DESCRIPTOR); - } - }, - }); + if (canMuteMembers) { + nextModerationActions.push({ + icon: ( + + ), + label: i18n._(VOICE_COMMUNITY_MUTE_DESCRIPTOR), + checked: isGuildMuted, + onChange: async (checked: boolean) => { + try { + await GuildMemberCommands.update(guildId, user.id, {mute: checked}); + if (checked) SoundCommands.playSound(SoundType.Mute); + else SoundCommands.playSound(SoundType.Unmute); + } catch (error) { + logger.error('Failed to update community mute:', error); + showVoiceMemberModerationFailedModal(error, VOICE_COMMUNITY_MUTE_DESCRIPTOR); + } + }, + }); + } + if (canDeafenMembers) { + nextModerationActions.push({ + icon: ( + + ), + label: i18n._(VOICE_COMMUNITY_DEAFEN_DESCRIPTOR), + checked: isGuildDeafened, + onChange: async (checked: boolean) => { + try { + await GuildMemberCommands.update(guildId, user.id, {deaf: checked}); + if (checked) SoundCommands.playSound(SoundType.Deaf); + else SoundCommands.playSound(SoundType.Undeaf); + } catch (error) { + logger.error('Failed to update community deafen:', error); + showVoiceMemberModerationFailedModal(error, VOICE_COMMUNITY_DEAFEN_DESCRIPTOR); + } + }, + }); + } } if (!isCurrentUser && canMoveMembers && canManageTarget(user.id) && !isParentGroupedItem) { nextModerationActions.push({ @@ -1396,6 +1401,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt canMention, focusedChannelId, canMuteMembers, + canDeafenMembers, canMoveMembers, canManageTarget, canKickTarget, diff --git a/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.test.ts b/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.test.ts index 51f2436d4..77da1de58 100644 --- a/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.test.ts +++ b/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.test.ts @@ -17,6 +17,7 @@ const DEFAULT_PERMISSIONS: VoiceChannelPermissions = { canUseVideo: true, canConnect: true, canPrioritySpeaker: false, + canUseVad: true, }; function permissions(overrides: Partial = {}): VoiceChannelPermissions { diff --git a/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.ts b/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.ts index 16063723d..3861cd1ba 100644 --- a/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.ts +++ b/fluxer_app/src/features/voice/engine/VoicePermissionStateMachine.ts @@ -62,6 +62,7 @@ export const DEFAULT_VOICE_PERMISSION_STATE: VoiceChannelPermissions = { canUseVideo: true, canConnect: true, canPrioritySpeaker: false, + canUseVad: true, }; const EMPTY_COMMANDS: ReadonlyArray = []; @@ -86,7 +87,8 @@ function permissionsEqual(left: VoiceChannelPermissions, right: VoiceChannelPerm left.canStream === right.canStream && left.canUseVideo === right.canUseVideo && left.canConnect === right.canConnect && - left.canPrioritySpeaker === right.canPrioritySpeaker + left.canPrioritySpeaker === right.canPrioritySpeaker && + left.canUseVad === right.canUseVad ); } diff --git a/fluxer_app/src/features/voice/utils/VoicePermissionUtils.ts b/fluxer_app/src/features/voice/utils/VoicePermissionUtils.ts index bfaa2766c..911904d69 100644 --- a/fluxer_app/src/features/voice/utils/VoicePermissionUtils.ts +++ b/fluxer_app/src/features/voice/utils/VoicePermissionUtils.ts @@ -11,6 +11,7 @@ export interface VoiceChannelPermissions { canUseVideo: boolean; canConnect: boolean; canPrioritySpeaker: boolean; + canUseVad: boolean; } export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = { @@ -19,6 +20,7 @@ export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = { canUseVideo: true, canConnect: true, canPrioritySpeaker: false, + canUseVad: true, }; export type VoicePermissionMuteReason = 'server_suppress' | 'missing_speak_permission' | null; @@ -38,6 +40,7 @@ export function getVoiceChannelPermissions(channelId: string | null | undefined) canUseVideo: (permissions & Permissions.STREAM) === Permissions.STREAM, canConnect: (permissions & Permissions.CONNECT) === Permissions.CONNECT, canPrioritySpeaker: (permissions & Permissions.PRIORITY_SPEAKER) === Permissions.PRIORITY_SPEAKER, + canUseVad: (permissions & Permissions.USE_VAD) === Permissions.USE_VAD, }; }