Revert "MM-63935 - abac end user indicators db changes (#8849)" (#8859)

This reverts commit d838f74273.
This commit is contained in:
Elias Nahum 2025-05-15 06:15:20 +08:00 committed by GitHub
parent d838f74273
commit 934ed2a773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 5 additions and 1012 deletions

View file

@ -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};
}
}

View file

@ -36,7 +36,6 @@ export interface ClientChannelsMix {
removeFromChannel: (userId: string, channelId: string) => Promise<any>;
getChannelStats: (channelId: string, groupLabel?: RequestGroupLabel) => Promise<ChannelStats>;
getChannelMemberCountsByGroup: (channelId: string, includeTimezones: boolean) => Promise<ChannelMemberCountByGroup[]>;
getChannelAccessControlAttributes: (channelId: string) => Promise<ChannelAccessControlAttributes>;
viewMyChannel: (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => Promise<any>;
autocompleteChannels: (teamId: string, name: string) => Promise<Channel[]>;
autocompleteChannelsForSearch: (teamId: string, name: string) => Promise<Channel[]>;
@ -288,13 +287,6 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(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};

View file

@ -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(
<AlertBanner
{...baseProps}
/>,
);
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(
<AlertBanner {...props}/>,
);
expect(getByText('Test description')).toBeTruthy();
});
it('should render tags when provided', () => {
const props = {
...baseProps,
tags: ['Tag1', 'Tag2'],
};
const {getByText} = render(
<AlertBanner {...props}/>,
);
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(
<AlertBanner {...props}/>,
);
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(
<AlertBanner {...props}/>,
);
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(
<AlertBanner {...props}/>,
);
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(
<AlertBanner {...props}/>,
);
expect(getByTestId('test-banner')).toBeTruthy();
});
it('should render with error type', () => {
const props = {
...baseProps,
type: 'error' as const,
};
const {getByTestId} = render(
<AlertBanner {...props}/>,
);
expect(getByTestId('test-banner')).toBeTruthy();
});
});

View file

@ -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 (
<View
style={containerStyle}
testID={testID}
accessibilityRole='alert'
>
<ContentWrapper
style={styles.content}
{...contentWrapperProps}
>
<CompassIcon
name={iconName}
size={20}
style={iconStyle}
testID={testID ? (testID + '-icon') : undefined}
accessibilityLabel={`${type} icon`}
/>
<View style={styles.body}>
<Text style={styles.message}>
{message}
</Text>
{description && (
<Text style={styles.description}>
{description}
</Text>
)}
{tags && tags.length > 0 && (
<View style={styles.tagsContainer}>
{tags.map((tag, index) => (
<Tag
key={tag + '-' + index}
text={tag}
type={type}
/>
))}
</View>
)}
</View>
{isDismissable && onDismiss && (
<Pressable
onPress={handleDismiss}
style={styles.dismissIcon}
testID={testID ? (testID + '-dismiss') : undefined}
accessibilityRole='button'
accessibilityLabel='Dismiss'
>
<CompassIcon
name='close'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</Pressable>
)}
</ContentWrapper>
</View>
);
};
export default AlertBanner;

View file

@ -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};

View file

@ -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(
<Tag
text='Test Tag'
type='info'
/>,
);
expect(getByText('Test Tag')).toBeTruthy();
});
it('should have accessibility role', () => {
const {getByRole} = render(
<Tag
text='Test Tag'
type='info'
/>,
);
expect(getByRole('text')).toBeTruthy();
});
});

View file

@ -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 (
<View
style={[styles.tag, styles.tagBackground]}
accessibilityRole='text'
>
<Text style={[styles.tagText, styles.tagTextColor]}>
{text}
</Text>
</View>
);
};
export default Tag;

View file

@ -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: [

View file

@ -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<ChannelMembershipModel>;
@ -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,
};
};
}

View file

@ -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({

View file

@ -43,7 +43,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 11,
version: 10,
tables: [
CategorySchema,
CategoryChannelSchema,

View file

@ -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},
],
});

View file

@ -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]: {

View file

@ -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;
}
});
});

View file

@ -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<string, {
processedTags: string[];
timestamp: number;
}> = {};
// 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<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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<string, string[]> | 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,
};
};

View file

@ -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 && (
<AlertBanner
type='info'
message={formatMessage({
id: 'channel.abac_policy_enforced.title',
defaultMessage: 'Channel access is restricted by user attributes',
})}
description={formatMessage({
id: 'channel.abac_policy_enforced.description',
defaultMessage: 'Only people who match the specified access rules can be selected and added to this channel.',
})}
tags={attributeTags.length > 0 ? attributeTags : undefined}
isDismissable={true}
onDismiss={() => setShowBanner(false)}
testID={`${TEST_ID}.alert_banner`}
/>
)}
<View style={style.searchBar}>
<Search
testID={`${TEST_ID}.search_bar`}
@ -319,3 +296,4 @@ export default function ChannelAddMembers({
</SafeAreaView>
);
}

View file

@ -37,7 +37,6 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
canChangeMemberRoles,
teammateDisplayNameSetting,
channel: currentChannel,
};
});

View file

@ -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<UserProfile[]>(EMPTY);
const [channelMembers, setChannelMembers] = useState<ChannelMembership[]>(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 && (
<AlertBanner
type='info'
message={formatMessage({
id: 'channel.abac_policy_enforced.title',
defaultMessage: 'Channel access is restricted by user attributes',
})}
description={formatMessage({
id: 'channel.abac_policy_enforced.description',
defaultMessage: 'Only people who match the specified access rules can be selected and added to this channel.',
})}
tags={attributeTags.length > 0 ? attributeTags : undefined}
testID='manage_members.alert_banner'
/>
)}
<View style={styles.searchBar}>
<Search
autoCapitalize='none'

View file

@ -233,8 +233,6 @@
"channel_notification_preferences.reset_default": "Reset to default",
"channel_notification_preferences.thread_replies": "Thread replies",
"channel_notification_preferences.unmute_content": "Unmute channel",
"channel.abac_policy_enforced.description": "Only people who match the specified access rules can be selected and added to this channel.",
"channel.abac_policy_enforced.title": "Channel access is restricted by user attributes",
"channel.banner.bottom_sheet.title": "Channel Banner",
"combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.",
"combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.",

View file

@ -45,9 +45,6 @@ type Channel = {
group_constrained: boolean|null;
shared: boolean;
banner_info?: ChannelBannerInfo;
/** Whether the channel has Attribute-Based Access Control (ABAC) policy enforcement enabled, controlling access based on user attributes */
policy_enforced?: boolean;
};
type ChannelPatch = {
name?: string;
@ -173,5 +170,3 @@ type ChannelBannerInfo = {
text?: string;
background_color?: string;
}
type ChannelAccessControlAttributes = Record<string, string[]>;

View file

@ -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<ChannelMembershipModel>;