From 934ed2a773822dce40445b851a518df47711621f Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 15 May 2025 06:15:20 +0800 Subject: [PATCH] Revert "MM-63935 - abac end user indicators db changes (#8849)" (#8859) This reverts commit d838f74273763a8f536e7fdd0e623cd1a30ffbfc. --- .../channel_access_control_attributes.ts | 26 -- app/client/rest/channels.ts | 8 - .../alert_banner/alert_banner.test.tsx | 140 -------- app/components/alert_banner/alert_banner.tsx | 194 ----------- app/components/alert_banner/index.tsx | 8 - app/components/alert_banner/tag.test.tsx | 37 -- app/components/alert_banner/tag.tsx | 55 --- app/database/migration/server/index.ts | 11 - app/database/models/server/channel.ts | 4 - .../transformers/channel.ts | 1 - app/database/schema/server/index.ts | 2 +- .../schema/server/table_schemas/channel.ts | 1 - app/database/schema/server/test.ts | 5 +- app/hooks/access_control_attributes.test.ts | 328 ------------------ app/hooks/access_control_attributes.ts | 136 -------- .../channel_add_members.tsx | 26 +- app/screens/manage_channel_members/index.tsx | 1 - .../manage_channel_members.tsx | 24 -- assets/base/i18n/en.json | 2 - types/api/channels.d.ts | 5 - types/database/models/servers/channel.ts | 3 - 21 files changed, 5 insertions(+), 1012 deletions(-) delete mode 100644 app/actions/remote/channel_access_control_attributes.ts delete mode 100644 app/components/alert_banner/alert_banner.test.tsx delete mode 100644 app/components/alert_banner/alert_banner.tsx delete mode 100644 app/components/alert_banner/index.tsx delete mode 100644 app/components/alert_banner/tag.test.tsx delete mode 100644 app/components/alert_banner/tag.tsx delete mode 100644 app/hooks/access_control_attributes.test.ts delete mode 100644 app/hooks/access_control_attributes.ts diff --git a/app/actions/remote/channel_access_control_attributes.ts b/app/actions/remote/channel_access_control_attributes.ts deleted file mode 100644 index 60b9d6b12..000000000 --- a/app/actions/remote/channel_access_control_attributes.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import NetworkManager from '@managers/network_manager'; -import {getFullErrorMessage} from '@utils/errors'; -import {logDebug} from '@utils/log'; - -import {forceLogoutIfNecessary} from './session'; - -/** - * Fetches the channel access control attributes for a specific channel - * @param serverUrl - The server URL - * @param channelId - The ID of the channel - * @returns The channel access control attributes or an error - */ -export async function fetchChannelAccessControlAttributes(serverUrl: string, channelId: string) { - try { - const client = NetworkManager.getClient(serverUrl); - const attributes = await client.getChannelAccessControlAttributes(channelId); - return {attributes}; - } catch (error) { - logDebug('error on fetchChannelAccessControlAttributes', getFullErrorMessage(error)); - forceLogoutIfNecessary(serverUrl, error); - return {error}; - } -} diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 01b3eb8a7..9a05661b0 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -36,7 +36,6 @@ export interface ClientChannelsMix { removeFromChannel: (userId: string, channelId: string) => Promise; getChannelStats: (channelId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelMemberCountsByGroup: (channelId: string, includeTimezones: boolean) => Promise; - getChannelAccessControlAttributes: (channelId: string) => Promise; viewMyChannel: (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => Promise; autocompleteChannels: (teamId: string, name: string) => Promise; autocompleteChannelsForSearch: (teamId: string, name: string) => Promise; @@ -288,13 +287,6 @@ const ClientChannels = >(superclass: TBase ); }; - getChannelAccessControlAttributes = async (channelId: string) => { - return this.doFetch( - `${this.getChannelRoute(channelId)}/access_control/attributes`, - {method: 'get'}, - ); - }; - viewMyChannel = async (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => { // collapsed_threads_supported is not based on user preferences but to know if "CLIENT" supports CRT const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}; diff --git a/app/components/alert_banner/alert_banner.test.tsx b/app/components/alert_banner/alert_banner.test.tsx deleted file mode 100644 index 77b129129..000000000 --- a/app/components/alert_banner/alert_banner.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {fireEvent, render} from '@testing-library/react-native'; -import React from 'react'; - -// We're mocking useTheme but not using it directly - -import AlertBanner from './alert_banner'; - -jest.mock('@context/theme', () => ({ - useTheme: jest.fn(() => ({ - sidebarTextActiveBorder: '#1C58D9', - awayIndicator: '#F5AB1E', - dndIndicator: '#D24B4E', - centerChannelColor: '#3F4350', - })), -})); - -describe('components/alert_banner', () => { - const baseProps = { - type: 'info' as const, - message: 'Test message', - testID: 'test-banner', - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should render correctly with base props', () => { - const {getByTestId, getByText} = render( - , - ); - - expect(getByTestId('test-banner')).toBeTruthy(); - expect(getByTestId('test-banner-icon')).toBeTruthy(); - expect(getByText('Test message')).toBeTruthy(); - }); - - it('should render description when provided', () => { - const props = { - ...baseProps, - description: 'Test description', - }; - - const {getByText} = render( - , - ); - - expect(getByText('Test description')).toBeTruthy(); - }); - - it('should render tags when provided', () => { - const props = { - ...baseProps, - tags: ['Tag1', 'Tag2'], - }; - - const {getByText} = render( - , - ); - - expect(getByText('Tag1')).toBeTruthy(); - expect(getByText('Tag2')).toBeTruthy(); - }); - - it('should render dismiss button when isDismissable is true', () => { - const props = { - ...baseProps, - isDismissable: true, - onDismiss: jest.fn(), - }; - - const {getByTestId} = render( - , - ); - - expect(getByTestId('test-banner-dismiss')).toBeTruthy(); - }); - - it('should call onDismiss when dismiss button is pressed', () => { - const onDismiss = jest.fn(); - const props = { - ...baseProps, - isDismissable: true, - onDismiss, - }; - - const {getByTestId} = render( - , - ); - - fireEvent.press(getByTestId('test-banner-dismiss')); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it('should call onPress when banner is pressed', () => { - const onPress = jest.fn(); - const props = { - ...baseProps, - onPress, - }; - - const {getByTestId} = render( - , - ); - - fireEvent.press(getByTestId('test-banner').children[0]); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('should render with warning type', () => { - const props = { - ...baseProps, - type: 'warning' as const, - }; - - const {getByTestId} = render( - , - ); - - expect(getByTestId('test-banner')).toBeTruthy(); - }); - - it('should render with error type', () => { - const props = { - ...baseProps, - type: 'error' as const, - }; - - const {getByTestId} = render( - , - ); - - expect(getByTestId('test-banner')).toBeTruthy(); - }); -}); diff --git a/app/components/alert_banner/alert_banner.tsx b/app/components/alert_banner/alert_banner.tsx deleted file mode 100644 index e9ec6469e..000000000 --- a/app/components/alert_banner/alert_banner.tsx +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback, useMemo} from 'react'; -import { - Pressable, - Text, - TouchableOpacity, - View, -} from 'react-native'; - -import CompassIcon from '@components/compass_icon'; -import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -import Tag from './tag'; - -type AlertBannerProps = { - type: 'info' | 'warning' | 'error'; - message: string; - description?: string; - tags?: string[]; - isDismissable?: boolean; - onDismiss?: () => void; - onPress?: () => void; - testID?: string; -} - -const iconByType = { - info: 'information-outline', - warning: 'alert-outline', - error: 'alert-circle-outline', -}; - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return { - container: { - borderWidth: 0, - borderBottomWidth: 1, - borderTopWidth: 1, - borderStyle: 'solid', - borderColor: changeOpacity(theme.centerChannelColor, 0.08), - overflow: 'hidden', - }, - content: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - }, - body: { - flex: 1, - flexDirection: 'column', - marginLeft: 8, - }, - message: { - ...typography('Body', 100, 'SemiBold'), - color: theme.centerChannelColor, - }, - description: { - ...typography('Body', 100, 'Regular'), - color: theme.centerChannelColor, - marginTop: 4, - }, - tagsContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - marginTop: 8, - }, - dismissIcon: { - marginLeft: 8, - padding: 4, - }, - - // Info styles - infoContainer: { - borderColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16), - backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.08), - }, - infoIcon: { - color: theme.sidebarTextActiveBorder, - }, - - // Warning styles - warningContainer: { - borderColor: changeOpacity(theme.awayIndicator, 0.16), - backgroundColor: changeOpacity(theme.awayIndicator, 0.08), - }, - warningIcon: { - color: theme.awayIndicator, - }, - - // Error styles - errorContainer: { - borderColor: changeOpacity(theme.dndIndicator, 0.16), - backgroundColor: changeOpacity(theme.dndIndicator, 0.08), - }, - errorIcon: { - color: theme.dndIndicator, - }, - }; -}); - -const AlertBanner = ({ - type = 'info', - message, - description, - tags, - isDismissable = false, - onDismiss, - onPress, - testID, -}: AlertBannerProps) => { - const theme = useTheme(); - const styles = getStyleFromTheme(theme); - const iconName = iconByType[type]; - - const handleDismiss = useCallback(() => { - if (onDismiss) { - onDismiss(); - } - }, [onDismiss]); - - const containerStyle = useMemo(() => [ - styles.container, - styles[`${type}Container`], - ], [styles, type]); - - const iconStyle = useMemo(() => [ - styles[`${type}Icon`], - ], [styles, type]); - - const ContentWrapper = onPress ? TouchableOpacity : View; - const contentWrapperProps = onPress ? {onPress} : {}; - - return ( - - - - - - {message} - - {description && ( - - {description} - - )} - {tags && tags.length > 0 && ( - - {tags.map((tag, index) => ( - - ))} - - )} - - {isDismissable && onDismiss && ( - - - - )} - - - ); -}; - -export default AlertBanner; diff --git a/app/components/alert_banner/index.tsx b/app/components/alert_banner/index.tsx deleted file mode 100644 index 8a57f9199..000000000 --- a/app/components/alert_banner/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import AlertBanner from './alert_banner'; -import Tag from './tag'; - -export default AlertBanner; -export {Tag}; diff --git a/app/components/alert_banner/tag.test.tsx b/app/components/alert_banner/tag.test.tsx deleted file mode 100644 index 778e4e50c..000000000 --- a/app/components/alert_banner/tag.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {render} from '@testing-library/react-native'; -import React from 'react'; - -import Tag from './tag'; - -jest.mock('@context/theme', () => ({ - useTheme: jest.fn(() => ({ - centerChannelColor: '#3F4350', - })), -})); - -describe('components/alert_banner/tag', () => { - it('should render correctly with text', () => { - const {getByText} = render( - , - ); - - expect(getByText('Test Tag')).toBeTruthy(); - }); - - it('should have accessibility role', () => { - const {getByRole} = render( - , - ); - - expect(getByRole('text')).toBeTruthy(); - }); -}); diff --git a/app/components/alert_banner/tag.tsx b/app/components/alert_banner/tag.tsx deleted file mode 100644 index 544bbb68e..000000000 --- a/app/components/alert_banner/tag.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Text, View} from 'react-native'; - -import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -type TagProps = { - text: string; - type: 'info' | 'warning' | 'error'; -} - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return { - tag: { - borderRadius: 4, - paddingHorizontal: 12, - paddingVertical: 4, - marginRight: 8, - marginBottom: 4, - }, - tagText: { - ...typography('Heading', 75, 'SemiBold'), - }, - - // All tag types use the same styling based on design requirements - tagBackground: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), - }, - tagTextColor: { - color: theme.centerChannelColor, - }, - }; -}); - -const Tag = ({text}: TagProps) => { - const theme = useTheme(); - const styles = getStyleFromTheme(theme); - - return ( - - - {text} - - - ); -}; - -export default Tag; diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index e2d6eacb6..80a16d6ca 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -11,17 +11,6 @@ import {MM_TABLES} from '@constants/database'; const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, FILE, POST, CHANNEL, CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD, SCHEDULED_POST} = MM_TABLES.SERVER; export default schemaMigrations({migrations: [ - { - toVersion: 11, - steps: [ - addColumns({ - table: CHANNEL, - columns: [ - {name: 'abac_policy_enforced', type: 'boolean', isOptional: true}, - ], - }), - ], - }, { toVersion: 10, steps: [ diff --git a/app/database/models/server/channel.ts b/app/database/models/server/channel.ts index 6d59e2145..ebaab298f 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -112,9 +112,6 @@ export default class ChannelModel extends Model implements ChannelModelInterface /** bannerInfo : The banner information for the channel */ @json('banner_info', safeParseJSON) bannerInfo?: ChannelBannerInfo; - /** policy_enforced : Whether the Attribute-Based Access Control (ABAC) policy is enforced for this channel, controlling access based on user attributes */ - @field('abac_policy_enforced') abacPolicyEnforced?: boolean; - /** members : Users belonging to this channel */ @children(CHANNEL_MEMBERSHIP) members!: Query; @@ -166,7 +163,6 @@ export default class ChannelModel extends Model implements ChannelModelInterface group_constrained: null, shared: this.shared, banner_info: this.bannerInfo, - policy_enforced: this.abacPolicyEnforced, }; }; } diff --git a/app/database/operator/server_data_operator/transformers/channel.ts b/app/database/operator/server_data_operator/transformers/channel.ts index a47f5b4aa..9358b4940 100644 --- a/app/database/operator/server_data_operator/transformers/channel.ts +++ b/app/database/operator/server_data_operator/transformers/channel.ts @@ -52,7 +52,6 @@ export const transformChannelRecord = ({action, database, value}: TransformerArg channel.teamId = raw.team_id; channel.type = raw.type; channel.bannerInfo = raw.banner_info; - channel.abacPolicyEnforced = Boolean(raw.policy_enforced); }; return prepareBaseRecord({ diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 69d31d744..2d35d92f7 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -43,7 +43,7 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 11, + version: 10, tables: [ CategorySchema, CategoryChannelSchema, diff --git a/app/database/schema/server/table_schemas/channel.ts b/app/database/schema/server/table_schemas/channel.ts index 3ee49976a..1619f976e 100644 --- a/app/database/schema/server/table_schemas/channel.ts +++ b/app/database/schema/server/table_schemas/channel.ts @@ -21,6 +21,5 @@ export default tableSchema({ {name: 'type', type: 'string'}, {name: 'update_at', type: 'number'}, {name: 'banner_info', type: 'string', isOptional: true}, - {name: 'abac_policy_enforced', type: 'boolean', isOptional: true}, ], }); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index 867a99a66..64e914eba 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -49,7 +49,7 @@ const { describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 11, + version: 10, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -126,7 +126,7 @@ describe('*** Test schema for SERVER database ***', () => { type: {name: 'type', type: 'string'}, update_at: {name: 'update_at', type: 'number'}, banner_info: {name: 'banner_info', type: 'string', isOptional: true}, - abac_policy_enforced: {name: 'abac_policy_enforced', type: 'boolean', isOptional: true}, + }, columnArray: [ {name: 'create_at', type: 'number'}, @@ -140,7 +140,6 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'type', type: 'string'}, {name: 'update_at', type: 'number'}, {name: 'banner_info', type: 'string', isOptional: true}, - {name: 'abac_policy_enforced', type: 'boolean', isOptional: true}, ], }, [CHANNEL_BOOKMARK]: { diff --git a/app/hooks/access_control_attributes.test.ts b/app/hooks/access_control_attributes.test.ts deleted file mode 100644 index 1137df657..000000000 --- a/app/hooks/access_control_attributes.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {renderHook, act} from '@testing-library/react-hooks'; - -import * as ChannelAccessControlAttributesActions from '@actions/remote/channel_access_control_attributes'; -import {useServerUrl} from '@context/server'; - -// Access the module-level cache to reset it between tests -// This is necessary because the cache persists between test runs -// @ts-ignore - Accessing private module variable for testing -import * as AccessControlAttributesModule from './access_control_attributes'; -import {useAccessControlAttributes} from './access_control_attributes'; - -const MOCK_SERVER_URL = 'https://test-server.mattermost.com'; - -jest.mock('@context/server', () => ({ - useServerUrl: jest.fn(() => MOCK_SERVER_URL), -})); - -jest.mock('@actions/remote/channel_access_control_attributes', () => ({ - fetchChannelAccessControlAttributes: jest.fn(), -})); - -describe('useAccessControlAttributes', () => { - const mockServerUrl = MOCK_SERVER_URL; - const mockEntityId = 'channel-id'; - const mockAttributes = { - groups: ['group1', 'group2'], - locations: ['location1'], - departments: ['department1', 'department2', 'department3'], - }; - - beforeEach(() => { - jest.resetAllMocks(); - (useServerUrl as jest.Mock).mockReturnValue(mockServerUrl); - - // Clear the module-level cache before each test - // @ts-ignore - Accessing private module variable for testing - if (AccessControlAttributesModule.attributesCache) { - // @ts-ignore - Accessing private module variable for testing - Object.keys(AccessControlAttributesModule.attributesCache).forEach((key) => { - // @ts-ignore - Accessing private module variable for testing - delete AccessControlAttributesModule.attributesCache[key]; - }); - } - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should return empty tags when abac policy is not enforced', async () => { - const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, false)); - - expect(result.current.attributeTags).toEqual([]); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled(); - }); - - it('should return empty tags when entityId is undefined', async () => { - const {result} = renderHook(() => useAccessControlAttributes('channel', undefined, true)); - - expect(result.current.attributeTags).toEqual([]); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled(); - }); - - it('should fetch and process attributes when abac policy is enforced', async () => { - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({ - attributes: mockAttributes, - }); - - const {result, waitForNextUpdate} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Initial state might be false if the hook sets loading after the first render - // expect(result.current.loading).toBe(true); - expect(result.current.attributeTags).toEqual([]); - - // Wait for the fetch to complete - await waitForNextUpdate(); - - // Check that the fetch was called with the correct parameters - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith( - mockServerUrl, - mockEntityId, - ); - - // Check that the attributes were processed correctly - expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('should handle errors when fetching attributes', async () => { - const mockError = new Error('Failed to fetch attributes'); - - // Reset all mocks to ensure a clean state - jest.resetAllMocks(); - - // Use mockRejectedValueOnce instead of mockImplementation for more explicit rejection - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockRejectedValueOnce(mockError); - - // Render the hook - const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Manually trigger a fetch with forceRefresh to bypass cache - await act(async () => { - await result.current.fetchAttributes(true); - }); - - // Check that the error was handled correctly - expect(result.current.attributeTags).toEqual([]); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(mockError); - }); - - it('should handle null or undefined values in attributes', async () => { - // Clear any previous mock implementations - jest.resetAllMocks(); - - // Set up the mock to return data with null/undefined values - const mockDataWithNulls = { - attributes: { - groups: ['group1', null, undefined, 'group2'], - locations: null, - departments: undefined, - }, - }; - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(mockDataWithNulls); - - // Render the hook with act to handle all state updates - const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Wait for the initial fetch to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Check that null and undefined values were filtered out - expect(result.current.attributeTags).toEqual(['group1', 'group2']); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('should handle non-object attributes', async () => { - // Clear any previous mock implementations - jest.resetAllMocks(); - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({ - attributes: 'not an object', - }); - - // Render the hook - const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Manually trigger a fetch with forceRefresh to bypass cache - await act(async () => { - await result.current.fetchAttributes(true); - }); - - // Check that non-object attributes result in empty tags - expect(result.current.attributeTags).toEqual([]); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('should handle non-array values in attributes', async () => { - // Clear any previous mock implementations - jest.resetAllMocks(); - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({ - attributes: { - groups: 'not an array', - locations: 123, - departments: {key: 'value'}, - }, - }); - - // Render the hook - const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Manually trigger a fetch with forceRefresh to bypass cache - await act(async () => { - await result.current.fetchAttributes(true); - }); - - // Check that non-array values were handled correctly - expect(result.current.attributeTags).toEqual([]); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - }); - - // This test verifies that the hook correctly handles dependency changes - it('should refetch when dependencies change', async () => { - // Mock the API call to return different data for different entity IDs - const firstEntityData = { - attributes: { - groups: ['group1', 'group2'], - }, - }; - - const secondEntityData = { - attributes: { - departments: ['department1', 'department2'], - }, - }; - - // Set up the mock to return the first data set - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(firstEntityData); - - // Render the hook with the first entity ID and manually trigger a fetch - const {result: result1} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Manually trigger a fetch to avoid waiting for the automatic fetch - await act(async () => { - await result1.current.fetchAttributes(true); // Use forceRefresh to bypass cache - }); - - // Verify the API was called with the correct parameters - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith( - mockServerUrl, - mockEntityId, - ); - - // Verify the correct data was processed - expect(result1.current.attributeTags).toEqual(['group1', 'group2']); - - // Reset mocks for the second test - jest.clearAllMocks(); - - // Set up the mock to return the second data set - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(secondEntityData); - - // Render the hook with a different entity ID - const newEntityId = 'new-channel-id'; - const {result: result2} = renderHook(() => useAccessControlAttributes('channel', newEntityId, true)); - - // Manually trigger a fetch - await act(async () => { - await result2.current.fetchAttributes(true); // Use forceRefresh to bypass cache - }); - - // Verify the API was called with the new entity ID - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith( - mockServerUrl, - newEntityId, - ); - - // Verify the correct data was processed - expect(result2.current.attributeTags).toEqual(['department1', 'department2']); - }); - - it('should use cached data when available and within TTL', async () => { - // Mock Date.now to control time - const originalDateNow = Date.now; - const mockNow = jest.fn(); - const currentTime = 1000; - mockNow.mockReturnValue(currentTime); - global.Date.now = mockNow; - - try { - // Directly manipulate the cache instead of relying on the hook to populate it - // @ts-ignore - Accessing private module variable for testing - AccessControlAttributesModule.attributesCache = {}; - - // Create the cache key the same way the hook does - const cacheKey = `${mockServerUrl}:channel:${mockEntityId}`; - - // @ts-ignore - Accessing private module variable for testing - AccessControlAttributesModule.attributesCache[cacheKey] = { - processedTags: ['group1', 'group2', 'location1', 'department1', 'department2', 'department3'], - timestamp: currentTime - 1000, // Set timestamp to 1 second ago - }; - - // Mock the API call to return the same data as the cache - // This is important because the hook will still try to fetch data when it mounts - jest.resetAllMocks(); - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({ - attributes: mockAttributes, - }); - - // Render the hook - it should use the cache we just set - const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); - - // Wait a bit to ensure the hook has time to process - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Check that the cached data was used - expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBe(null); - - // The hook will still make an API call when it mounts, even if the cache is populated - // So we just verify that the API call is made only once and that the hook uses the cached data - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1); - - // Advance time beyond TTL - mockNow.mockReturnValue(currentTime + (5 * 60 * 1000) + 1); // 5 minutes + 1ms later - - // Reset mock call count and set up a different mock response for the second render - jest.clearAllMocks(); - (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({ - attributes: { - differentData: ['should', 'not', 'be', 'used'], - }, - }); - - // Render again - should fetch from API because cache expired - const {result: result2} = renderHook(() => - useAccessControlAttributes('channel', mockEntityId, true), - ); - - // Wait for the hook to update - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1); - expect(result2.current.attributeTags).toEqual(['should', 'not', 'be', 'used']); - } finally { - // Restore original Date.now - global.Date.now = originalDateNow; - } - }); -}); diff --git a/app/hooks/access_control_attributes.ts b/app/hooks/access_control_attributes.ts deleted file mode 100644 index d993f2b7c..000000000 --- a/app/hooks/access_control_attributes.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useCallback, useEffect, useRef, useState} from 'react'; - -import {fetchChannelAccessControlAttributes} from '@actions/remote/channel_access_control_attributes'; -import {useServerUrl} from '@context/server'; - -// Module-level cache for access control attributes -// The cache stores processed tags with a timestamp to implement a TTL (time-to-live) -const attributesCache: Record = {}; - -// Cache TTL in milliseconds (5 minutes) -const CACHE_TTL = 5 * 60 * 1000; - -/** - * A hook for fetching access control attributes for a channel. - * - * @param entityType - The type of entity (e.g., 'channel') - * @param entityId - The ID of the entity - * @param hasAbacPolicyEnforced - Whether the entity has abac policy enforcement enabled - * @returns An object containing the attribute tags, loading state, and fetch function - */ -export const useAccessControlAttributes = ( - entityType: 'channel', - entityId: string | undefined, - hasAbacPolicyEnforced: boolean | undefined, -) => { - const serverUrl = useServerUrl(); - const [attributeTags, setAttributeTags] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // Use useRef to track if the component is mounted - const isMounted = useRef(true); - - // Cleanup on unmount - useEffect(() => { - return () => { - isMounted.current = false; - }; - }, []); - - // Helper function to process attribute data and extract tags - const processAttributeData = useCallback((data: Record | undefined) => { - if (!data || typeof data !== 'object') { - return []; - } - - const tags: string[] = []; - - // Process each entry in the attributes object - for (const values of Object.values(data)) { - if (Array.isArray(values)) { - for (const value of values) { - if (value !== undefined && value !== null) { - tags.push(`${value}`); - } - } - } - } - - return tags; - }, []); - - const fetchAttributes = useCallback(async (forceRefresh = false) => { - if (!entityId || !hasAbacPolicyEnforced) { - return; - } - - setLoading(true); - setError(null); - - try { - // Check cache first - const cacheKey = `${serverUrl}:${entityType}:${entityId}`; - const cachedEntry = attributesCache[cacheKey]; - const now = Date.now(); - - // Use cache if it exists and is not too old and forceRefresh is false - if (!forceRefresh && cachedEntry && (now - cachedEntry.timestamp < CACHE_TTL)) { - // Use the pre-processed tags directly from the cache - setAttributeTags(cachedEntry.processedTags); - setLoading(false); - return; - } - - // If no cache or cache expired, fetch from API - if (entityType === 'channel') { - const result = await fetchChannelAccessControlAttributes(serverUrl, entityId); - - if (isMounted.current) { - if (result?.attributes && typeof result.attributes === 'object') { - // Process the data once - const tags = processAttributeData(result.attributes); - - // Store only the processed tags in cache - attributesCache[cacheKey] = { - processedTags: tags, - timestamp: now, - }; - - // Update the state - setAttributeTags(tags); - } else { - setAttributeTags([]); - } - } - } - } catch (err) { - if (isMounted.current) { - setError(err as Error); - setAttributeTags([]); - } - } finally { - if (isMounted.current) { - setLoading(false); - } - } - }, [entityType, entityId, hasAbacPolicyEnforced, serverUrl, processAttributeData]); - - // Fetch attributes when the component mounts or when dependencies change - useEffect(() => { - fetchAttributes(); - }, [fetchAttributes]); - - return { - attributeTags, - loading, - error, - fetchAttributes, - }; -}; diff --git a/app/screens/channel_add_members/channel_add_members.tsx b/app/screens/channel_add_members/channel_add_members.tsx index 9f212132f..1188d5436 100644 --- a/app/screens/channel_add_members/channel_add_members.tsx +++ b/app/screens/channel_add_members/channel_add_members.tsx @@ -8,7 +8,6 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import {addMembersToChannel} from '@actions/remote/channel'; import {fetchProfilesNotInChannel, searchProfiles} from '@actions/remote/user'; -import AlertBanner from '@components/alert_banner'; import CompassIcon from '@components/compass_icon'; import Loading from '@components/loading'; import Search from '@components/search'; @@ -17,7 +16,6 @@ import ServerUserList from '@components/server_user_list'; import {General, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {useAccessControlAttributes} from '@hooks/access_control_attributes'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useKeyboardOverlap} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; @@ -135,10 +133,6 @@ export default function ChannelAddMembers({ const [term, setTerm] = useState(''); const [addingMembers, setAddingMembers] = useState(false); const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({}); - const [showBanner, setShowBanner] = useState(true); - - // Use the hook to fetch access control attributes - const {attributeTags} = useAccessControlAttributes('channel', channel?.id, channel?.abacPolicyEnforced); const clearSearch = useCallback(() => { setTerm(''); @@ -245,7 +239,7 @@ export default function ChannelAddMembers({ useEffect(() => { updateNavigationButtons(); - }, [updateNavigationButtons, channel, serverUrl]); + }, [updateNavigationButtons]); if (addingMembers) { return ( @@ -264,23 +258,6 @@ export default function ChannelAddMembers({ edges={['top', 'left', 'right']} nativeID={SecurityManager.getShieldScreenId(componentId)} > - {showBanner && channel?.abacPolicyEnforced && ( - 0 ? attributeTags : undefined} - isDismissable={true} - onDismiss={() => setShowBanner(false)} - testID={`${TEST_ID}.alert_banner`} - /> - )} ); } + diff --git a/app/screens/manage_channel_members/index.tsx b/app/screens/manage_channel_members/index.tsx index ae1ba4f22..90cb163fe 100644 --- a/app/screens/manage_channel_members/index.tsx +++ b/app/screens/manage_channel_members/index.tsx @@ -37,7 +37,6 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS), canChangeMemberRoles, teammateDisplayNameSetting, - channel: currentChannel, }; }); diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx index 2eecadb09..5905264ae 100644 --- a/app/screens/manage_channel_members/manage_channel_members.tsx +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -9,13 +9,11 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import {fetchChannelMemberships} from '@actions/remote/channel'; import {fetchUsersByIds, searchProfiles} from '@actions/remote/user'; import {PER_PAGE_DEFAULT} from '@client/rest/constants'; -import AlertBanner from '@components/alert_banner'; import Search from '@components/search'; import UserList from '@components/user_list'; import {Events, General, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {useAccessControlAttributes} from '@hooks/access_control_attributes'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import SecurityManager from '@managers/security_manager'; @@ -25,7 +23,6 @@ import {showRemoveChannelUserSnackbar} from '@utils/snack_bar'; import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; import {displayUsername, filterProfilesMatchingTerm} from '@utils/user'; -import type ChannelModel from '@typings/database/models/servers/channel'; import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { @@ -36,7 +33,6 @@ type Props = { currentUserId: string; tutorialWatched: boolean; teammateDisplayNameSetting: string; - channel?: ChannelModel; } const styles = StyleSheet.create({ @@ -82,7 +78,6 @@ export default function ManageChannelMembers({ currentUserId, tutorialWatched, teammateDisplayNameSetting, - channel: channelProp, }: Props) { const serverUrl = useServerUrl(); const theme = useTheme(); @@ -93,9 +88,6 @@ export default function ManageChannelMembers({ const hasMoreProfiles = useRef(true); const pageRef = useRef(0); - // Use the hook to fetch access control attributes - const {attributeTags} = useAccessControlAttributes('channel', channelId, channelProp?.abacPolicyEnforced); - const [isManageMode, setIsManageMode] = useState(false); const [profiles, setProfiles] = useState(EMPTY); const [channelMembers, setChannelMembers] = useState(EMPTY_MEMBERS); @@ -302,22 +294,6 @@ export default function ManageChannelMembers({ testID='manage_members.screen' nativeID={SecurityManager.getShieldScreenId(componentId)} > - {/* or if the channel has abac_policy_enforced=true */} - {channelProp?.abacPolicyEnforced === true && ( - 0 ? attributeTags : undefined} - testID='manage_members.alert_banner' - /> - )} ; diff --git a/types/database/models/servers/channel.ts b/types/database/models/servers/channel.ts index fa7e33fb3..38d160318 100644 --- a/types/database/models/servers/channel.ts +++ b/types/database/models/servers/channel.ts @@ -56,9 +56,6 @@ declare class ChannelModel extends Model { bannerInfo?: ChannelBannerInfo; - /** Whether the channel has Attribute-Based Access Control (ABAC) policy enforcement enabled, controlling access based on user attributes */ - abacPolicyEnforced?: boolean; - /** members : Users belonging to this channel */ members: Query;