diff --git a/app/actions/remote/channel_access_control_attributes.ts b/app/actions/remote/channel_access_control_attributes.ts new file mode 100644 index 000000000..60b9d6b12 --- /dev/null +++ b/app/actions/remote/channel_access_control_attributes.ts @@ -0,0 +1,26 @@ +// 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 9a05661b0..01b3eb8a7 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -36,6 +36,7 @@ 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; @@ -287,6 +288,13 @@ 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 new file mode 100644 index 000000000..77b129129 --- /dev/null +++ b/app/components/alert_banner/alert_banner.test.tsx @@ -0,0 +1,140 @@ +// 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 new file mode 100644 index 000000000..e9ec6469e --- /dev/null +++ b/app/components/alert_banner/alert_banner.tsx @@ -0,0 +1,194 @@ +// 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 new file mode 100644 index 000000000..8a57f9199 --- /dev/null +++ b/app/components/alert_banner/index.tsx @@ -0,0 +1,8 @@ +// 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 new file mode 100644 index 000000000..778e4e50c --- /dev/null +++ b/app/components/alert_banner/tag.test.tsx @@ -0,0 +1,37 @@ +// 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 new file mode 100644 index 000000000..544bbb68e --- /dev/null +++ b/app/components/alert_banner/tag.tsx @@ -0,0 +1,55 @@ +// 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 80a16d6ca..e2d6eacb6 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -11,6 +11,17 @@ 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 ebaab298f..6d59e2145 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -112,6 +112,9 @@ 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; @@ -163,6 +166,7 @@ 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 9358b4940..a47f5b4aa 100644 --- a/app/database/operator/server_data_operator/transformers/channel.ts +++ b/app/database/operator/server_data_operator/transformers/channel.ts @@ -52,6 +52,7 @@ 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 2d35d92f7..69d31d744 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: 10, + version: 11, tables: [ CategorySchema, CategoryChannelSchema, diff --git a/app/database/schema/server/table_schemas/channel.ts b/app/database/schema/server/table_schemas/channel.ts index 1619f976e..3ee49976a 100644 --- a/app/database/schema/server/table_schemas/channel.ts +++ b/app/database/schema/server/table_schemas/channel.ts @@ -21,5 +21,6 @@ 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 64e914eba..867a99a66 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: 10, + version: 11, 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,6 +140,7 @@ 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 new file mode 100644 index 000000000..1137df657 --- /dev/null +++ b/app/hooks/access_control_attributes.test.ts @@ -0,0 +1,328 @@ +// 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 new file mode 100644 index 000000000..d993f2b7c --- /dev/null +++ b/app/hooks/access_control_attributes.ts @@ -0,0 +1,136 @@ +// 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 1188d5436..9f212132f 100644 --- a/app/screens/channel_add_members/channel_add_members.tsx +++ b/app/screens/channel_add_members/channel_add_members.tsx @@ -8,6 +8,7 @@ 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'; @@ -16,6 +17,7 @@ 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'; @@ -133,6 +135,10 @@ 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(''); @@ -239,7 +245,7 @@ export default function ChannelAddMembers({ useEffect(() => { updateNavigationButtons(); - }, [updateNavigationButtons]); + }, [updateNavigationButtons, channel, serverUrl]); if (addingMembers) { return ( @@ -258,6 +264,23 @@ 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 90cb163fe..ae1ba4f22 100644 --- a/app/screens/manage_channel_members/index.tsx +++ b/app/screens/manage_channel_members/index.tsx @@ -37,6 +37,7 @@ 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 5905264ae..2eecadb09 100644 --- a/app/screens/manage_channel_members/manage_channel_members.tsx +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -9,11 +9,13 @@ 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'; @@ -23,6 +25,7 @@ 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 = { @@ -33,6 +36,7 @@ type Props = { currentUserId: string; tutorialWatched: boolean; teammateDisplayNameSetting: string; + channel?: ChannelModel; } const styles = StyleSheet.create({ @@ -78,6 +82,7 @@ export default function ManageChannelMembers({ currentUserId, tutorialWatched, teammateDisplayNameSetting, + channel: channelProp, }: Props) { const serverUrl = useServerUrl(); const theme = useTheme(); @@ -88,6 +93,9 @@ 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); @@ -294,6 +302,22 @@ 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 38d160318..fa7e33fb3 100644 --- a/types/database/models/servers/channel.ts +++ b/types/database/models/servers/channel.ts @@ -56,6 +56,9 @@ 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;