From d87351eefef16fa217967a65e04d965887cf3eff Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 2 Oct 2026 17:23:50 +0200 Subject: [PATCH] fix(privacy): let minors block media in DMs from others (#3120) --- .../services/UserAccountSettingsService.ts | 16 ++++++--- ...UserSettingsSensitiveContentFilter.test.ts | 29 ++++++++++++++++ .../SensitiveContentTab.tsx | 34 ++++++++++--------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/fluxer_api/src/api/user/services/UserAccountSettingsService.ts b/fluxer_api/src/api/user/services/UserAccountSettingsService.ts index 28c6b0351..9e0c1e61b 100644 --- a/fluxer_api/src/api/user/services/UserAccountSettingsService.ts +++ b/fluxer_api/src/api/user/services/UserAccountSettingsService.ts @@ -226,11 +226,17 @@ export class UserAccountSettingsService { updatedRowData.sensitive_content_friend_dm_filter = data.sensitive_content_friend_dm_filter; } if (data.sensitive_content_non_friend_dm_filter !== undefined) { - throw ValidationError.fromPath( - 'sensitive_content_non_friend_dm_filter', - 'AGE_RESTRICTED', - 'Non-adult users cannot modify the non-friend DM content filter', - ); + const allowed = + data.sensitive_content_non_friend_dm_filter === SensitiveMediaFilterLevel.BLUR || + data.sensitive_content_non_friend_dm_filter === SensitiveMediaFilterLevel.BLOCK; + if (!allowed) { + throw ValidationError.fromPath( + 'sensitive_content_non_friend_dm_filter', + 'AGE_RESTRICTED', + 'Non-adult users can only set non-friend DM filter to blur or block', + ); + } + updatedRowData.sensitive_content_non_friend_dm_filter = data.sensitive_content_non_friend_dm_filter; } if (data.sensitive_content_guild_filter !== undefined) { throw ValidationError.fromPath( diff --git a/fluxer_api/src/api/user/tests/UserSettingsSensitiveContentFilter.test.ts b/fluxer_api/src/api/user/tests/UserSettingsSensitiveContentFilter.test.ts index 2595ada60..dafc5fd49 100644 --- a/fluxer_api/src/api/user/tests/UserSettingsSensitiveContentFilter.test.ts +++ b/fluxer_api/src/api/user/tests/UserSettingsSensitiveContentFilter.test.ts @@ -100,6 +100,35 @@ describe('User Settings - Sensitive Content Filters', () => { expect(response.errors[0]?.path).toBe('sensitive_content_friend_dm_filter'); expect(response.errors[0]?.code).toBe(ValidationErrorCodes.AGE_RESTRICTED); }); + test('lets a non-adult account tighten the non-friend DM filter', async () => { + const account = await createTestAccount(harness, {dateOfBirth: '2010-01-01'}); + const {json} = await updateUserSettings(harness, account.token, { + sensitive_content_non_friend_dm_filter: SensitiveMediaFilterLevel.BLUR, + }); + expect(json.sensitive_content_non_friend_dm_filter).toBe(SensitiveMediaFilterLevel.BLUR); + const {json: blocked} = await updateUserSettings(harness, account.token, { + sensitive_content_non_friend_dm_filter: SensitiveMediaFilterLevel.BLOCK, + }); + expect(blocked.sensitive_content_non_friend_dm_filter).toBe(SensitiveMediaFilterLevel.BLOCK); + }); + test('rejects a non-adult account relaxing the non-friend DM filter', async () => { + const account = await createTestAccount(harness, {dateOfBirth: '2010-01-01'}); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + code: string; + message: string; + }>; + }>(harness, account.token) + .patch('/users/@me/settings') + .body({sensitive_content_non_friend_dm_filter: SensitiveMediaFilterLevel.SHOW}) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + expect(response.code).toBe('VALIDATION_ERROR'); + expect(response.errors[0]?.path).toBe('sensitive_content_non_friend_dm_filter'); + expect(response.errors[0]?.code).toBe(ValidationErrorCodes.AGE_RESTRICTED); + }); test('allows an account without a birth date to relax every filter when none is collected', async () => { const repository = getInstanceConfigRepository(); const previous = (await repository.getAppPublicConfig()).registration.collect_date_of_birth; diff --git a/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/SensitiveContentTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/SensitiveContentTab.tsx index df1724d3d..0b893db0c 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/SensitiveContentTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/SensitiveContentTab.tsx @@ -51,6 +51,7 @@ const SENSITIVE_CONTENT_TAB_ID = 'privacy_safety'; interface SensitiveContentOption { value: number; label: string; + disabled?: boolean; } interface SensitiveContentChoiceRowProps { @@ -73,15 +74,16 @@ const SensitiveContentChoiceRow: React.FC = ({ const labelId = useId(); const optionRefs = useRef(new Map()); const selectedIndex = options.findIndex((option) => option.value === value); - const focusedIndex = selectedIndex >= 0 ? selectedIndex : 0; + const enabledOptions = options.filter((option) => !option.disabled); + const focusedValue = enabledOptions.some((option) => option.value === value) ? value : enabledOptions[0]?.value; const handleKeyDown = (event: React.KeyboardEvent, optionValue: number) => { if (disabled) return; - const currentIndex = options.findIndex((option) => option.value === optionValue); + const currentIndex = enabledOptions.findIndex((option) => option.value === optionValue); if (currentIndex < 0) return; const direction = getTabNavigationDirection(event.key, 'horizontal'); if (!direction) return; - const nextIndex = getNextTabIndex(currentIndex, options.length, direction); - const nextOption = nextIndex == null ? null : options[nextIndex]; + const nextIndex = getNextTabIndex(currentIndex, enabledOptions.length, direction); + const nextOption = nextIndex == null ? null : enabledOptions[nextIndex]; if (!nextOption) return; event.preventDefault(); event.stopPropagation(); @@ -101,7 +103,7 @@ const SensitiveContentChoiceRow: React.FC = ({ aria-disabled={disabled || undefined} data-flx={dataFlx} > - {options.map((option, index) => { + {options.map((option) => { const isSelected = option.value === value; return (