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:
Pablo Vélez 2025-05-19 15:34:03 +02:00 committed by GitHub
parent dbaf534a32
commit 1569f712d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 652 additions and 25 deletions

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

View file

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

View file

@ -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"

View file

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

View file

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

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

View 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,
};
};

View file

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

View file

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

View file

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

View file

@ -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"

View file

@ -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}.",

View file

@ -173,3 +173,5 @@ type ChannelBannerInfo = {
text?: string;
background_color?: string;
}
type ChannelAccessControlAttributes = Record<string, string[]>;