From 1569f712d2f144b6ae9539dd42571e0ea3cc529d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Mon, 19 May 2025 15:34:03 +0200 Subject: [PATCH] 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 --- .../channel_access_control_attributes.ts | 26 ++ app/client/rest/channels.ts | 8 + .../__snapshots__/index.test.tsx.snap | 9 +- app/components/section_notice/index.tsx | 47 ++- app/components/tag/index.tsx | 31 +- app/hooks/access_control_attributes.test.ts | 328 ++++++++++++++++++ app/hooks/access_control_attributes.ts | 136 ++++++++ .../channel_add_members.tsx | 38 +- app/screens/manage_channel_members/index.tsx | 6 +- .../manage_channel_members.tsx | 35 +- ...end_test_notification_notice.test.tsx.snap | 9 +- assets/base/i18n/en.json | 2 + types/api/channels.d.ts | 2 + 13 files changed, 652 insertions(+), 25 deletions(-) create mode 100644 app/actions/remote/channel_access_control_attributes.ts create mode 100644 app/hooks/access_control_attributes.test.ts create mode 100644 app/hooks/access_control_attributes.ts diff --git a/app/actions/remote/channel_access_control_attributes.ts b/app/actions/remote/channel_access_control_attributes.ts new file mode 100644 index 000000000..60b9d6b12 --- /dev/null +++ b/app/actions/remote/channel_access_control_attributes.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +import {forceLogoutIfNecessary} from './session'; + +/** + * Fetches the channel access control attributes for a specific channel + * @param serverUrl - The server URL + * @param channelId - The ID of the channel + * @returns The channel access control attributes or an error + */ +export async function fetchChannelAccessControlAttributes(serverUrl: string, channelId: string) { + try { + const client = NetworkManager.getClient(serverUrl); + const attributes = await client.getChannelAccessControlAttributes(channelId); + return {attributes}; + } catch (error) { + logDebug('error on fetchChannelAccessControlAttributes', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 9a05661b0..01b3eb8a7 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -36,6 +36,7 @@ export interface ClientChannelsMix { removeFromChannel: (userId: string, channelId: string) => Promise; getChannelStats: (channelId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelMemberCountsByGroup: (channelId: string, includeTimezones: boolean) => Promise; + getChannelAccessControlAttributes: (channelId: string) => Promise; viewMyChannel: (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => Promise; autocompleteChannels: (teamId: string, name: string) => Promise; autocompleteChannelsForSearch: (teamId: string, name: string) => Promise; @@ -287,6 +288,13 @@ const ClientChannels = >(superclass: TBase ); }; + getChannelAccessControlAttributes = async (channelId: string) => { + return this.doFetch( + `${this.getChannelRoute(channelId)}/access_control/attributes`, + {method: 'get'}, + ); + }; + viewMyChannel = async (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => { // collapsed_threads_supported is not based on user preferences but to know if "CLIENT" supports CRT const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}; diff --git a/app/components/section_notice/__snapshots__/index.test.tsx.snap b/app/components/section_notice/__snapshots__/index.test.tsx.snap index 6b6a6f44f..fea00be71 100644 --- a/app/components/section_notice/__snapshots__/index.test.tsx.snap +++ b/app/components/section_notice/__snapshots__/index.test.tsx.snap @@ -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" diff --git a/app/components/section_notice/index.tsx b/app/components/section_notice/index.tsx index 6c532c76e..5410c9dc8 100644 --- a/app/components/section_notice/index.tsx +++ b/app/components/section_notice/index.tsx @@ -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; + 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 ( {icon && ( )} @@ -188,6 +209,18 @@ const SectionNotice = ({ value={text} /> )} + {showTags && ( + + {tags.map((tag) => ( + + ))} + + )} {hasButtons && ( {primaryButton && ( diff --git a/app/components/tag/index.tsx b/app/components/tag/index.tsx index b1b27c879..aa6dba441 100644 --- a/app/components/tag/index.tsx +++ b/app/components/tag/index.tsx @@ -17,6 +17,7 @@ type TagProps = { style?: StyleProp; testID?: string; textStyle?: StyleProp; + 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) { ); } -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 ( - + diff --git a/app/hooks/access_control_attributes.test.ts b/app/hooks/access_control_attributes.test.ts new file mode 100644 index 000000000..1137df657 --- /dev/null +++ b/app/hooks/access_control_attributes.test.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; + +import * as ChannelAccessControlAttributesActions from '@actions/remote/channel_access_control_attributes'; +import {useServerUrl} from '@context/server'; + +// Access the module-level cache to reset it between tests +// This is necessary because the cache persists between test runs +// @ts-ignore - Accessing private module variable for testing +import * as AccessControlAttributesModule from './access_control_attributes'; +import {useAccessControlAttributes} from './access_control_attributes'; + +const MOCK_SERVER_URL = 'https://test-server.mattermost.com'; + +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn(() => MOCK_SERVER_URL), +})); + +jest.mock('@actions/remote/channel_access_control_attributes', () => ({ + fetchChannelAccessControlAttributes: jest.fn(), +})); + +describe('useAccessControlAttributes', () => { + const mockServerUrl = MOCK_SERVER_URL; + const mockEntityId = 'channel-id'; + const mockAttributes = { + groups: ['group1', 'group2'], + locations: ['location1'], + departments: ['department1', 'department2', 'department3'], + }; + + beforeEach(() => { + jest.resetAllMocks(); + (useServerUrl as jest.Mock).mockReturnValue(mockServerUrl); + + // Clear the module-level cache before each test + // @ts-ignore - Accessing private module variable for testing + if (AccessControlAttributesModule.attributesCache) { + // @ts-ignore - Accessing private module variable for testing + Object.keys(AccessControlAttributesModule.attributesCache).forEach((key) => { + // @ts-ignore - Accessing private module variable for testing + delete AccessControlAttributesModule.attributesCache[key]; + }); + } + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should return empty tags when abac policy is not enforced', async () => { + const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, false)); + + expect(result.current.attributeTags).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled(); + }); + + it('should return empty tags when entityId is undefined', async () => { + const {result} = renderHook(() => useAccessControlAttributes('channel', undefined, true)); + + expect(result.current.attributeTags).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled(); + }); + + it('should fetch and process attributes when abac policy is enforced', async () => { + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({ + attributes: mockAttributes, + }); + + const {result, waitForNextUpdate} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Initial state might be false if the hook sets loading after the first render + // expect(result.current.loading).toBe(true); + expect(result.current.attributeTags).toEqual([]); + + // Wait for the fetch to complete + await waitForNextUpdate(); + + // Check that the fetch was called with the correct parameters + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith( + mockServerUrl, + mockEntityId, + ); + + // Check that the attributes were processed correctly + expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle errors when fetching attributes', async () => { + const mockError = new Error('Failed to fetch attributes'); + + // Reset all mocks to ensure a clean state + jest.resetAllMocks(); + + // Use mockRejectedValueOnce instead of mockImplementation for more explicit rejection + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockRejectedValueOnce(mockError); + + // Render the hook + const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Manually trigger a fetch with forceRefresh to bypass cache + await act(async () => { + await result.current.fetchAttributes(true); + }); + + // Check that the error was handled correctly + expect(result.current.attributeTags).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(mockError); + }); + + it('should handle null or undefined values in attributes', async () => { + // Clear any previous mock implementations + jest.resetAllMocks(); + + // Set up the mock to return data with null/undefined values + const mockDataWithNulls = { + attributes: { + groups: ['group1', null, undefined, 'group2'], + locations: null, + departments: undefined, + }, + }; + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(mockDataWithNulls); + + // Render the hook with act to handle all state updates + const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Wait for the initial fetch to complete + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Check that null and undefined values were filtered out + expect(result.current.attributeTags).toEqual(['group1', 'group2']); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle non-object attributes', async () => { + // Clear any previous mock implementations + jest.resetAllMocks(); + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({ + attributes: 'not an object', + }); + + // Render the hook + const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Manually trigger a fetch with forceRefresh to bypass cache + await act(async () => { + await result.current.fetchAttributes(true); + }); + + // Check that non-object attributes result in empty tags + expect(result.current.attributeTags).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle non-array values in attributes', async () => { + // Clear any previous mock implementations + jest.resetAllMocks(); + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({ + attributes: { + groups: 'not an array', + locations: 123, + departments: {key: 'value'}, + }, + }); + + // Render the hook + const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Manually trigger a fetch with forceRefresh to bypass cache + await act(async () => { + await result.current.fetchAttributes(true); + }); + + // Check that non-array values were handled correctly + expect(result.current.attributeTags).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + // This test verifies that the hook correctly handles dependency changes + it('should refetch when dependencies change', async () => { + // Mock the API call to return different data for different entity IDs + const firstEntityData = { + attributes: { + groups: ['group1', 'group2'], + }, + }; + + const secondEntityData = { + attributes: { + departments: ['department1', 'department2'], + }, + }; + + // Set up the mock to return the first data set + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(firstEntityData); + + // Render the hook with the first entity ID and manually trigger a fetch + const {result: result1} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Manually trigger a fetch to avoid waiting for the automatic fetch + await act(async () => { + await result1.current.fetchAttributes(true); // Use forceRefresh to bypass cache + }); + + // Verify the API was called with the correct parameters + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith( + mockServerUrl, + mockEntityId, + ); + + // Verify the correct data was processed + expect(result1.current.attributeTags).toEqual(['group1', 'group2']); + + // Reset mocks for the second test + jest.clearAllMocks(); + + // Set up the mock to return the second data set + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(secondEntityData); + + // Render the hook with a different entity ID + const newEntityId = 'new-channel-id'; + const {result: result2} = renderHook(() => useAccessControlAttributes('channel', newEntityId, true)); + + // Manually trigger a fetch + await act(async () => { + await result2.current.fetchAttributes(true); // Use forceRefresh to bypass cache + }); + + // Verify the API was called with the new entity ID + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith( + mockServerUrl, + newEntityId, + ); + + // Verify the correct data was processed + expect(result2.current.attributeTags).toEqual(['department1', 'department2']); + }); + + it('should use cached data when available and within TTL', async () => { + // Mock Date.now to control time + const originalDateNow = Date.now; + const mockNow = jest.fn(); + const currentTime = 1000; + mockNow.mockReturnValue(currentTime); + global.Date.now = mockNow; + + try { + // Directly manipulate the cache instead of relying on the hook to populate it + // @ts-ignore - Accessing private module variable for testing + AccessControlAttributesModule.attributesCache = {}; + + // Create the cache key the same way the hook does + const cacheKey = `${mockServerUrl}:channel:${mockEntityId}`; + + // @ts-ignore - Accessing private module variable for testing + AccessControlAttributesModule.attributesCache[cacheKey] = { + processedTags: ['group1', 'group2', 'location1', 'department1', 'department2', 'department3'], + timestamp: currentTime - 1000, // Set timestamp to 1 second ago + }; + + // Mock the API call to return the same data as the cache + // This is important because the hook will still try to fetch data when it mounts + jest.resetAllMocks(); + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({ + attributes: mockAttributes, + }); + + // Render the hook - it should use the cache we just set + const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true)); + + // Wait a bit to ensure the hook has time to process + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Check that the cached data was used + expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + + // The hook will still make an API call when it mounts, even if the cache is populated + // So we just verify that the API call is made only once and that the hook uses the cached data + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1); + + // Advance time beyond TTL + mockNow.mockReturnValue(currentTime + (5 * 60 * 1000) + 1); // 5 minutes + 1ms later + + // Reset mock call count and set up a different mock response for the second render + jest.clearAllMocks(); + (ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({ + attributes: { + differentData: ['should', 'not', 'be', 'used'], + }, + }); + + // Render again - should fetch from API because cache expired + const {result: result2} = renderHook(() => + useAccessControlAttributes('channel', mockEntityId, true), + ); + + // Wait for the hook to update + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1); + expect(result2.current.attributeTags).toEqual(['should', 'not', 'be', 'used']); + } finally { + // Restore original Date.now + global.Date.now = originalDateNow; + } + }); +}); diff --git a/app/hooks/access_control_attributes.ts b/app/hooks/access_control_attributes.ts new file mode 100644 index 000000000..d993f2b7c --- /dev/null +++ b/app/hooks/access_control_attributes.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, useRef, useState} from 'react'; + +import {fetchChannelAccessControlAttributes} from '@actions/remote/channel_access_control_attributes'; +import {useServerUrl} from '@context/server'; + +// Module-level cache for access control attributes +// The cache stores processed tags with a timestamp to implement a TTL (time-to-live) +const attributesCache: Record = {}; + +// Cache TTL in milliseconds (5 minutes) +const CACHE_TTL = 5 * 60 * 1000; + +/** + * A hook for fetching access control attributes for a channel. + * + * @param entityType - The type of entity (e.g., 'channel') + * @param entityId - The ID of the entity + * @param hasAbacPolicyEnforced - Whether the entity has abac policy enforcement enabled + * @returns An object containing the attribute tags, loading state, and fetch function + */ +export const useAccessControlAttributes = ( + entityType: 'channel', + entityId: string | undefined, + hasAbacPolicyEnforced: boolean | undefined, +) => { + const serverUrl = useServerUrl(); + const [attributeTags, setAttributeTags] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Use useRef to track if the component is mounted + const isMounted = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + + // Helper function to process attribute data and extract tags + const processAttributeData = useCallback((data: Record | undefined) => { + if (!data || typeof data !== 'object') { + return []; + } + + const tags: string[] = []; + + // Process each entry in the attributes object + for (const values of Object.values(data)) { + if (Array.isArray(values)) { + for (const value of values) { + if (value !== undefined && value !== null) { + tags.push(`${value}`); + } + } + } + } + + return tags; + }, []); + + const fetchAttributes = useCallback(async (forceRefresh = false) => { + if (!entityId || !hasAbacPolicyEnforced) { + return; + } + + setLoading(true); + setError(null); + + try { + // Check cache first + const cacheKey = `${serverUrl}:${entityType}:${entityId}`; + const cachedEntry = attributesCache[cacheKey]; + const now = Date.now(); + + // Use cache if it exists and is not too old and forceRefresh is false + if (!forceRefresh && cachedEntry && (now - cachedEntry.timestamp < CACHE_TTL)) { + // Use the pre-processed tags directly from the cache + setAttributeTags(cachedEntry.processedTags); + setLoading(false); + return; + } + + // If no cache or cache expired, fetch from API + if (entityType === 'channel') { + const result = await fetchChannelAccessControlAttributes(serverUrl, entityId); + + if (isMounted.current) { + if (result?.attributes && typeof result.attributes === 'object') { + // Process the data once + const tags = processAttributeData(result.attributes); + + // Store only the processed tags in cache + attributesCache[cacheKey] = { + processedTags: tags, + timestamp: now, + }; + + // Update the state + setAttributeTags(tags); + } else { + setAttributeTags([]); + } + } + } + } catch (err) { + if (isMounted.current) { + setError(err as Error); + setAttributeTags([]); + } + } finally { + if (isMounted.current) { + setLoading(false); + } + } + }, [entityType, entityId, hasAbacPolicyEnforced, serverUrl, processAttributeData]); + + // Fetch attributes when the component mounts or when dependencies change + useEffect(() => { + fetchAttributes(); + }, [fetchAttributes]); + + return { + attributeTags, + loading, + error, + fetchAttributes, + }; +}; diff --git a/app/screens/channel_add_members/channel_add_members.tsx b/app/screens/channel_add_members/channel_add_members.tsx index 1188d5436..29a35837f 100644 --- a/app/screens/channel_add_members/channel_add_members.tsx +++ b/app/screens/channel_add_members/channel_add_members.tsx @@ -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 && ( + 0 ? attributeTags : undefined} + isDismissable={true} + onDismissClick={handleDismissBanner} + location={Screens.CHANNEL_ADD_MEMBERS} + testID={`${TEST_ID}.notice`} + containerStyle={style.flatBottomBanner} + iconSize={24} + tagsVariant='subtle' + /> + )} ); } - diff --git a/app/screens/manage_channel_members/index.tsx b/app/screens/manage_channel_members/index.tsx index 90cb163fe..74400d440 100644 --- a/app/screens/manage_channel_members/index.tsx +++ b/app/screens/manage_channel_members/index.tsx @@ -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(), + ), }; }); diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx index 5905264ae..c34eabb98 100644 --- a/app/screens/manage_channel_members/manage_channel_members.tsx +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -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(EMPTY); const [channelMembers, setChannelMembers] = useState(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 ( + {channelAbacPolicyEnforced && ( + 0 ? attributeTags : undefined} + location={Screens.MANAGE_CHANNEL_MEMBERS} + testID={`${TEST_ID}.notice`} + containerStyle={styles.flatBottomBanner} + iconSize={24} + tagsVariant='subtle' + /> + )} @@ -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} diff --git a/app/screens/settings/notifications/send_test_notification_notice/__snapshots__/send_test_notification_notice.test.tsx.snap b/app/screens/settings/notifications/send_test_notification_notice/__snapshots__/send_test_notification_notice.test.tsx.snap index 4a49342a9..9d69e64be 100644 --- a/app/screens/settings/notifications/send_test_notification_notice/__snapshots__/send_test_notification_notice.test.tsx.snap +++ b/app/screens/settings/notifications/send_test_notification_notice/__snapshots__/send_test_notification_notice.test.tsx.snap @@ -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" diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index da0298c7d..cca58db66 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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}.", diff --git a/types/api/channels.d.ts b/types/api/channels.d.ts index 90e62b331..974793459 100644 --- a/types/api/channels.d.ts +++ b/types/api/channels.d.ts @@ -173,3 +173,5 @@ type ChannelBannerInfo = { text?: string; background_color?: string; } + +type ChannelAccessControlAttributes = Record;