Mm 63935 abac end user indicators (#8848)
* MM-63935 - abac end user indicators * rename policy variables to clearly indicate are from abac * update attributes hook to cache processed data * use policyEnforce property * add missing type * rename policy_enforced to abac_policy_enforced part 1 * add channel policy enforced type * fix translation file * remove unnecesary stop propagation * use existing components * remove unnecesary files * fix snapshot * update snapshot * do not tie styles to the abac feature * remove unnecesary margin top * simplify props, add style for flat banner, remove unncesary index * simplify condition, extract inline to component function --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
dbaf534a32
commit
1569f712d2
13 changed files with 652 additions and 25 deletions
26
app/actions/remote/channel_access_control_attributes.ts
Normal file
26
app/actions/remote/channel_access_control_attributes.ts
Normal file
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ 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[]>;
|
||||
|
|
@ -287,6 +288,13 @@ 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};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ exports[`Section notice match snapshot 1`] = `
|
|||
"backgroundColor": "rgba(93,137,234,0.08)",
|
||||
"borderColor": "rgba(93,137,234,0.16)",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
testID="sectionNoticeContainer"
|
||||
|
|
@ -51,9 +52,9 @@ exports[`Section notice match snapshot 1`] = `
|
|||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans-SemiBold",
|
||||
"fontSize": 16,
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 24,
|
||||
"lineHeight": 20,
|
||||
"margin": 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -79,9 +80,9 @@ exports[`Section notice match snapshot 1`] = `
|
|||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontSize": 14,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
"lineHeight": 20,
|
||||
}
|
||||
}
|
||||
testID="markdown_text"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {Pressable, Text, View} from 'react-native';
|
||||
import {Pressable, Text, View, type StyleProp, type ViewStyle} from 'react-native';
|
||||
|
||||
import Tag from '@components/tag';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
|
@ -25,6 +26,11 @@ type Props = {
|
|||
isDismissable?: boolean;
|
||||
onDismissClick?: () => void;
|
||||
location: AvailableScreens;
|
||||
tags?: string[];
|
||||
tagsVariant?: 'default' | 'subtle';
|
||||
testID?: string;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
const iconByType = {
|
||||
|
|
@ -61,7 +67,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
title: {
|
||||
margin: 0,
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
welcomeTitle: {
|
||||
margin: 0,
|
||||
|
|
@ -70,7 +76,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
},
|
||||
baseText: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200, 'Regular'),
|
||||
...typography('Body', 100, 'Regular'),
|
||||
},
|
||||
infoText: {
|
||||
color: theme.centerChannelColor,
|
||||
|
|
@ -141,6 +147,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -154,6 +165,11 @@ const SectionNotice = ({
|
|||
text,
|
||||
type = 'info',
|
||||
location,
|
||||
tags,
|
||||
tagsVariant = 'default',
|
||||
testID,
|
||||
containerStyle,
|
||||
iconSize = 20,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleFromTheme(theme);
|
||||
|
|
@ -161,20 +177,25 @@ const SectionNotice = ({
|
|||
const icon = iconByType[type];
|
||||
const showDismiss = Boolean(isDismissable && onDismissClick);
|
||||
const hasButtons = primaryButton || secondaryButton || linkButton;
|
||||
const showTags = tags && tags.length > 0;
|
||||
|
||||
const containerStyle = useMemo(() => [styles.container, styles[`${type}Container`]], [type]);
|
||||
const combinedContainerStyle = useMemo(() => [
|
||||
styles.container,
|
||||
styles[`${type}Container`],
|
||||
containerStyle,
|
||||
], [type, containerStyle]);
|
||||
const iconStyle = useMemo(() => styles[`${type}Icon`], [type]);
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
testID={'sectionNoticeContainer'}
|
||||
style={combinedContainerStyle}
|
||||
testID={testID || 'sectionNoticeContainer'}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{icon && (
|
||||
<CompassIcon
|
||||
name={icon}
|
||||
style={iconStyle}
|
||||
size={20}
|
||||
size={iconSize}
|
||||
testID='sectionNoticeHeaderIcon'
|
||||
/>
|
||||
)}
|
||||
|
|
@ -188,6 +209,18 @@ const SectionNotice = ({
|
|||
value={text}
|
||||
/>
|
||||
)}
|
||||
{showTags && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
id={`tag.${tag}`}
|
||||
defaultMessage={tag}
|
||||
variant={tagsVariant}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{hasButtons && (
|
||||
<View style={styles.actions}>
|
||||
{primaryButton && (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type TagProps = {
|
|||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
variant?: 'default' | 'subtle';
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
|
|
@ -38,6 +39,19 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.15),
|
||||
color: changeOpacity(theme.sidebarHeaderTextColor, 0.6),
|
||||
},
|
||||
|
||||
// Variant styles
|
||||
subtleContainer: {
|
||||
borderRadius: 6,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtleText: {
|
||||
fontSize: 12,
|
||||
textTransform: 'none',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -67,7 +81,7 @@ export function GuestTag(props: Omit<TagProps, 'id' | 'defaultMessage'>) {
|
|||
);
|
||||
}
|
||||
|
||||
const Tag = ({id, defaultMessage, inTitle, show = true, style, testID, textStyle}: TagProps) => {
|
||||
const Tag = ({id, defaultMessage, inTitle, show = true, style, testID, textStyle, variant = 'default'}: TagProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!show) {
|
||||
|
|
@ -77,11 +91,22 @@ const Tag = ({id, defaultMessage, inTitle, show = true, style, testID, textStyle
|
|||
const styles = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
variant === 'subtle' && styles.subtleContainer,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<FormattedText
|
||||
id={id}
|
||||
defaultMessage={defaultMessage}
|
||||
style={[styles.text, inTitle ? styles.title : null, textStyle]}
|
||||
style={[
|
||||
styles.text,
|
||||
inTitle ? styles.title : null,
|
||||
variant === 'subtle' && styles.subtleText,
|
||||
textStyle,
|
||||
]}
|
||||
testID={testID}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
328
app/hooks/access_control_attributes.test.ts
Normal file
328
app/hooks/access_control_attributes.test.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
136
app/hooks/access_control_attributes.ts
Normal file
136
app/hooks/access_control_attributes.ts
Normal file
|
|
@ -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<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,
|
||||
};
|
||||
};
|
||||
|
|
@ -11,11 +11,13 @@ import {fetchProfilesNotInChannel, searchProfiles} from '@actions/remote/user';
|
|||
import CompassIcon from '@components/compass_icon';
|
||||
import Loading from '@components/loading';
|
||||
import Search from '@components/search';
|
||||
import SectionNotice from '@components/section_notice';
|
||||
import SelectedUsers from '@components/selected_users';
|
||||
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';
|
||||
|
|
@ -102,6 +104,10 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
...typography('Body', 600, 'Regular'),
|
||||
},
|
||||
flatBottomBanner: {
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -133,6 +139,14 @@ export default function ChannelAddMembers({
|
|||
const [term, setTerm] = useState('');
|
||||
const [addingMembers, setAddingMembers] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
|
||||
const [showBanner, setShowBanner] = useState(Boolean(channel?.abacPolicyEnforced));
|
||||
|
||||
// Use the hook to fetch access control attributes
|
||||
const {attributeTags} = useAccessControlAttributes('channel', channel?.id, channel?.abacPolicyEnforced);
|
||||
|
||||
const handleDismissBanner = useCallback(() => {
|
||||
setShowBanner(false);
|
||||
}, []);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setTerm('');
|
||||
|
|
@ -239,7 +253,7 @@ export default function ChannelAddMembers({
|
|||
|
||||
useEffect(() => {
|
||||
updateNavigationButtons();
|
||||
}, [updateNavigationButtons]);
|
||||
}, [updateNavigationButtons, channel, serverUrl]);
|
||||
|
||||
if (addingMembers) {
|
||||
return (
|
||||
|
|
@ -258,6 +272,27 @@ export default function ChannelAddMembers({
|
|||
edges={['top', 'left', 'right']}
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
||||
>
|
||||
{showBanner && (
|
||||
<SectionNotice
|
||||
type='info'
|
||||
title={formatMessage({
|
||||
id: 'channel.abac_policy_enforced.title',
|
||||
defaultMessage: 'Channel access is restricted by user attributes',
|
||||
})}
|
||||
text={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}
|
||||
onDismissClick={handleDismissBanner}
|
||||
location={Screens.CHANNEL_ADD_MEMBERS}
|
||||
testID={`${TEST_ID}.notice`}
|
||||
containerStyle={style.flatBottomBanner}
|
||||
iconSize={24}
|
||||
tagsVariant='subtle'
|
||||
/>
|
||||
)}
|
||||
<View style={style.searchBar}>
|
||||
<Search
|
||||
testID={`${TEST_ID}.search_bar`}
|
||||
|
|
@ -296,4 +331,3 @@ export default function ChannelAddMembers({
|
|||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {of as of$, combineLatest, switchMap} from 'rxjs';
|
||||
import {of as of$, combineLatest, switchMap, distinctUntilChanged} from 'rxjs';
|
||||
|
||||
import {Permissions, Tutorial} from '@constants';
|
||||
import {observeTutorialWatched} from '@queries/app/global';
|
||||
|
|
@ -37,6 +37,10 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
|||
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
|
||||
canChangeMemberRoles,
|
||||
teammateDisplayNameSetting,
|
||||
channelAbacPolicyEnforced: currentChannel.pipe(
|
||||
switchMap((channel) => of$(channel?.abacPolicyEnforced || false)),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import {fetchChannelMemberships} from '@actions/remote/channel';
|
|||
import {fetchUsersByIds, searchProfiles} from '@actions/remote/user';
|
||||
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
|
||||
import Search from '@components/search';
|
||||
import SectionNotice from '@components/section_notice';
|
||||
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';
|
||||
|
|
@ -33,6 +35,7 @@ type Props = {
|
|||
currentUserId: string;
|
||||
tutorialWatched: boolean;
|
||||
teammateDisplayNameSetting: string;
|
||||
channelAbacPolicyEnforced: boolean;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
@ -44,6 +47,10 @@ const styles = StyleSheet.create({
|
|||
marginRight: Platform.select({ios: 4, default: 12}),
|
||||
marginVertical: 12,
|
||||
},
|
||||
flatBottomBanner: {
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -69,6 +76,7 @@ const EMPTY_MEMBERS: ChannelMembership[] = [];
|
|||
const EMPTY_IDS = {};
|
||||
const {USER_PROFILE} = Screens;
|
||||
const CLOSE_BUTTON_ID = 'close-user-profile';
|
||||
const TEST_ID = 'manage_members';
|
||||
|
||||
export default function ManageChannelMembers({
|
||||
canManageAndRemoveMembers,
|
||||
|
|
@ -78,6 +86,7 @@ export default function ManageChannelMembers({
|
|||
currentUserId,
|
||||
tutorialWatched,
|
||||
teammateDisplayNameSetting,
|
||||
channelAbacPolicyEnforced,
|
||||
}: Props) {
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
|
|
@ -88,6 +97,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, channelAbacPolicyEnforced);
|
||||
|
||||
const [isManageMode, setIsManageMode] = useState(false);
|
||||
const [profiles, setProfiles] = useState<UserProfile[]>(EMPTY);
|
||||
const [channelMembers, setChannelMembers] = useState<ChannelMembership[]>(EMPTY_MEMBERS);
|
||||
|
|
@ -175,7 +187,7 @@ export default function ManageChannelMembers({
|
|||
enabled: true,
|
||||
id: MANAGE_BUTTON,
|
||||
showAsAction: 'always',
|
||||
testID: 'manage_members.button',
|
||||
testID: `${TEST_ID}.button`,
|
||||
text: formatMessage(manage ? messages.button_done : messages.button_manage),
|
||||
}],
|
||||
});
|
||||
|
|
@ -291,9 +303,24 @@ export default function ManageChannelMembers({
|
|||
return (
|
||||
<SafeAreaView
|
||||
style={styles.container}
|
||||
testID='manage_members.screen'
|
||||
testID={`${TEST_ID}.screen`}
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
||||
>
|
||||
{channelAbacPolicyEnforced && (
|
||||
<SectionNotice
|
||||
type='info'
|
||||
title={formatMessage({
|
||||
id: 'channel.abac_policy_enforced.title',
|
||||
defaultMessage: 'Channel access is restricted by user attributes',
|
||||
})}
|
||||
tags={attributeTags.length > 0 ? attributeTags : undefined}
|
||||
location={Screens.MANAGE_CHANNEL_MEMBERS}
|
||||
testID={`${TEST_ID}.notice`}
|
||||
containerStyle={styles.flatBottomBanner}
|
||||
iconSize={24}
|
||||
tagsVariant='subtle'
|
||||
/>
|
||||
)}
|
||||
<View style={styles.searchBar}>
|
||||
<Search
|
||||
autoCapitalize='none'
|
||||
|
|
@ -304,7 +331,7 @@ export default function ManageChannelMembers({
|
|||
onSubmitEditing={search}
|
||||
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
testID='manage_members.search_bar'
|
||||
testID={`${TEST_ID}.search_bar`}
|
||||
value={term}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -319,7 +346,7 @@ export default function ManageChannelMembers({
|
|||
showManageMode={canManageAndRemoveMembers && isManageMode}
|
||||
showNoResults={!loading}
|
||||
term={searchedTerm}
|
||||
testID='manage_members.user_list'
|
||||
testID={`${TEST_ID}.user_list`}
|
||||
tutorialWatched={tutorialWatched}
|
||||
includeUserMargin={true}
|
||||
fetchMore={handleReachedBottom}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
|
|||
"backgroundColor": "rgba(93,137,234,0.08)",
|
||||
"borderColor": "rgba(93,137,234,0.16)",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
testID="sectionNoticeContainer"
|
||||
|
|
@ -60,9 +61,9 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
|
|||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans-SemiBold",
|
||||
"fontSize": 16,
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 24,
|
||||
"lineHeight": 20,
|
||||
"margin": 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -88,9 +89,9 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
|
|||
{
|
||||
"color": "#3f4350",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontSize": 14,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
"lineHeight": 20,
|
||||
}
|
||||
}
|
||||
testID="markdown_text"
|
||||
|
|
|
|||
|
|
@ -233,6 +233,8 @@
|
|||
"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}.",
|
||||
|
|
|
|||
2
types/api/channels.d.ts
vendored
2
types/api/channels.d.ts
vendored
|
|
@ -173,3 +173,5 @@ type ChannelBannerInfo = {
|
|||
text?: string;
|
||||
background_color?: string;
|
||||
}
|
||||
|
||||
type ChannelAccessControlAttributes = Record<string, string[]>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue