MM-64330 - filter abac users in channel invite (#31219)

* MM-64330 - filter abac users in channel invite

* implement cursor functionality for abac user filtering

* remove unnecessary comments

* refactor the backend implementation simplifying the functions

* refactor api to use opts as parameters, rename function

* add missing translation

* remove unnecesary test code

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Pablo Vélez
2025-06-20 10:53:14 +02:00
коммит произвёл GitHub
родитель 968550d275
Коммит 5fc74cd401
11 изменённых файлов: 346 добавлений и 21 удалений

Просмотреть файл

@@ -124,6 +124,14 @@ describe('components/channel_invite_modal', () => {
onExited: jest.fn(),
};
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
window.requestAnimationFrame = (_cb: FrameRequestCallback): number => {
// do not call cb at all
return 0;
};
});
test('should match snapshot for channel_invite_modal with profiles', () => {
const wrapper = shallowWithIntl(
<ChannelInviteModal
@@ -589,4 +597,97 @@ describe('components/channel_invite_modal', () => {
// Check that no tags are shown
expect(wrapper.find('AlertTag').exists()).toBe(false);
});
// the multiselect returns several elements with the same text, usiing a custom function to get the correct one specifing the tagName
const getUserSpan = (user: string) =>
screen.getByText((text, element) =>
element?.tagName === 'SPAN' && text.trim() === user,
) as HTMLElement;
test('should not include DM users when ABAC is enabled', async () => {
const channelWithPolicy = {...channel, policy_enforced: true};
const props = {
...baseProps,
channel: channelWithPolicy,
profilesNotInCurrentChannel: [users[0]],
profilesFromRecentDMs: [users[1]],
};
await act(async () => {
renderWithContext(<ChannelInviteModal {...props}/>);
});
const input = screen.getByRole('combobox', {name: /search for people/i});
await userEvent.type(input, 'user');
// now only one visible <span> should match "user-1"
expect(getUserSpan('user-1')).toBeInTheDocument();
// and no <span> with "user-2"
expect(screen.queryByText('user-2')).toBeNull();
});
test('should include DM users when ABAC is disabled', async () => {
const channelWithoutPolicy = {...channel, policy_enforced: false};
const props = {
...baseProps,
channel: channelWithoutPolicy,
profilesNotInCurrentChannel: [users[0]],
profilesFromRecentDMs: [users[1]],
};
await act(async () => {
renderWithContext(<ChannelInviteModal {...props}/>);
});
const input = screen.getByRole('combobox', {name: /search for people/i});
await userEvent.type(input, 'user');
// we should see both user-1 and user-2 in visible spans
expect(getUserSpan('user-1')).toBeInTheDocument();
expect(getUserSpan('user-2')).toBeInTheDocument();
});
test('should not reload data when search term is empty and ABAC is disabled', async () => {
const getProfilesNotInChannelMock = jest.fn().mockImplementation(() => Promise.resolve());
const channelWithoutPolicy = {
...channel,
policy_enforced: false,
};
const props = {
...baseProps,
channel: channelWithoutPolicy,
actions: {
...baseProps.actions,
getProfilesNotInChannel: getProfilesNotInChannelMock,
},
};
// Render the component
await act(async () => {
renderWithContext(
<ChannelInviteModal {...props}/>,
);
});
// Reset the mock after component mount to ignore initial data loading
getProfilesNotInChannelMock.mockClear();
// Find the search input
const input = screen.getByRole('combobox', {name: /search for people/i});
// Type something and then clear it
await userEvent.type(input, 'a');
await userEvent.clear(input);
// Wait for the search timeout
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// Should not call getProfilesNotInChannel after clearing the search
expect(getProfilesNotInChannelMock).not.toHaveBeenCalled();
});
});

Просмотреть файл

@@ -73,7 +73,7 @@ export type Props = {
isGroupsEnabled: boolean;
actions: {
addUsersToChannel: (channelId: string, userIds: string[]) => Promise<ActionResult>;
getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page: number, perPage?: number) => Promise<ActionResult>;
getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page: number, perPage?: number, cursorId?: string) => Promise<ActionResult>;
getProfilesInChannel: (channelId: string, page: number, perPage: number, sort: string, options: {active?: boolean}) => Promise<ActionResult>;
getTeamStats: (teamId: string) => void;
loadStatusesForProfilesList: (users: UserProfile[]) => void;
@@ -99,6 +99,7 @@ const ChannelInviteModalComponent = (props: Props) => {
const [loadingUsers, setLoadingUsers] = useState(true);
const [groupAndUserOptions, setGroupAndUserOptions] = useState<Array<UserProfileValue | GroupValue>>([]);
const [inviteError, setInviteError] = useState<string | undefined>(undefined);
const [pageCursors, setPageCursors] = useState<{[page: number]: string}>({});
const searchTimeoutId = useRef<number>(0);
const selectedItemRef = useRef<HTMLDivElement>(null);
@@ -173,15 +174,29 @@ const ChannelInviteModalComponent = (props: Props) => {
const getOptions = useCallback(() => {
const excludedAndNotInTeamUserIds = excludedUsers;
const filteredDmUsers = filterProfilesStartingWithTerm(props.profilesFromRecentDMs, term);
const dmUsers = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredDmUsers, excludedAndNotInTeamUserIds).slice(0, USERS_FROM_DMS) as UserProfileValue[];
// Only include DM users if ABAC is not enabled
let dmUsers: UserProfileValue[] = [];
if (!props.channel.policy_enforced) {
const filteredDmUsers = filterProfilesStartingWithTerm(props.profilesFromRecentDMs, term);
dmUsers = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredDmUsers, excludedAndNotInTeamUserIds).slice(0, USERS_FROM_DMS) as UserProfileValue[];
}
let users: UserProfileValue[];
const filteredUsers: UserProfile[] = filterProfilesStartingWithTerm(props.profilesNotInCurrentChannel.concat(props.profilesInCurrentChannel), term);
users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
if (props.includeUsers) {
if (props.channel.policy_enforced) {
// When ABAC is enabled, only use the ABAC-filtered profilesNotInCurrentChannel
const filteredUsers = filterProfilesStartingWithTerm(props.profilesNotInCurrentChannel, term);
users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
} else {
// When ABAC is not enabled, use the current logic
const filteredUsers = filterProfilesStartingWithTerm(props.profilesNotInCurrentChannel.concat(props.profilesInCurrentChannel), term);
users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
}
// Only include explicitly added users if ABAC is not enabled
if (props.includeUsers && !props.channel.policy_enforced) {
users = [...users, ...Object.values(props.includeUsers)];
}
const groupsAndUsers = [
...filterGroupsMatchingTerm(props.groups, term) as GroupValue[],
...users,
@@ -200,6 +215,7 @@ const ChannelInviteModalComponent = (props: Props) => {
props.profilesInCurrentChannel,
props.includeUsers,
props.groups,
props.channel.policy_enforced,
excludedUsers,
filterOutDeletedAndExcludedAndNotInTeamUsers,
]);
@@ -231,19 +247,38 @@ const ChannelInviteModalComponent = (props: Props) => {
setLoadingUsers(loadingState);
}, []);
// Handle page change
// Handle page change with cursor-based pagination
const handlePageChange = useCallback((page: number, prevPage: number) => {
if (page > prevPage) {
setUsersLoadingState(true);
// Get cursor for this page (if we're going forward)
const cursorId = page > 0 ? pageCursors[page - 1] : '';
props.actions.getProfilesNotInChannel(
props.channel.team_id,
props.channel.id,
props.channel.group_constrained,
page + 1, USERS_PER_PAGE).then(() => setUsersLoadingState(false));
page + 1,
USERS_PER_PAGE,
cursorId,
).then((result) => {
// Store the cursor for the next page (ID of the last user)
if (result.data && result.data.length > 0) {
const lastUserId = result.data[result.data.length - 1].id;
setPageCursors((prev) => ({
...prev,
[page]: lastUserId,
}));
}
setUsersLoadingState(false);
}).catch(() => {
setUsersLoadingState(false);
});
props.actions.getProfilesInChannel(props.channel.id, page + 1, USERS_PER_PAGE, '', {active: true});
}
}, [props.actions, props.channel, setUsersLoadingState]);
}, [props.actions, props.channel, setUsersLoadingState, pageCursors, setPageCursors]);
// Handle form submission
const handleSubmit = useCallback(() => {
@@ -282,7 +317,8 @@ const ChannelInviteModalComponent = (props: Props) => {
setTerm(term);
if (!term) {
// If the search term is empty, don't make any API calls
// Reset cursor state when clearing search
setPageCursors({});
setUsersLoadingState(false);
return;
}
@@ -403,7 +439,7 @@ const ChannelInviteModalComponent = (props: Props) => {
// Initial data loading - only run when channel changes or component mounts
useEffect(() => {
props.actions.getProfilesNotInChannel(props.channel.team_id, props.channel.id, props.channel.group_constrained, 0).then(() => {
props.actions.getProfilesNotInChannel(props.channel.team_id, props.channel.id, props.channel.group_constrained, 0, USERS_PER_PAGE).then(() => {
setUsersLoadingState(false);
});
props.actions.getProfilesInChannel(props.channel.id, 0, USERS_PER_PAGE, '', {active: true});

Просмотреть файл

@@ -432,12 +432,12 @@ export function getProfilesInGroupChannels(channelsIds: string[]): ActionFuncAsy
};
}
export function getProfilesNotInChannel(teamId: string, channelId: string, groupConstrained: boolean, page: number, perPage: number = General.PROFILE_CHUNK_SIZE): ActionFuncAsync<UserProfile[]> {
export function getProfilesNotInChannel(teamId: string, channelId: string, groupConstrained: boolean, page: number, perPage: number = General.PROFILE_CHUNK_SIZE, cursorId = ''): ActionFuncAsync<UserProfile[]> {
return async (dispatch, getState) => {
let profiles;
try {
profiles = await Client4.getProfilesNotInChannel(teamId, channelId, groupConstrained, page, perPage);
profiles = await Client4.getProfilesNotInChannel(teamId, channelId, groupConstrained, page, perPage, cursorId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));

Просмотреть файл

@@ -898,8 +898,16 @@ export default class Client4 {
);
};
getProfilesNotInChannel = (teamId: string, channelId: string, groupConstrained: boolean, page = 0, perPage = PER_PAGE_DEFAULT) => {
const queryStringObj: any = {in_team: teamId, not_in_channel: channelId, page, per_page: perPage};
getProfilesNotInChannel = (teamId: string, channelId: string, groupConstrained: boolean, page = 0, perPage = PER_PAGE_DEFAULT, cursorId = '') => {
const queryStringObj: any = {in_team: teamId, not_in_channel: channelId, per_page: perPage};
// If cursorId is provided, use cursor-based pagination
if (cursorId) {
queryStringObj.cursor_id = cursorId;
} else {
// Otherwise use traditional page-based pagination
queryStringObj.page = page;
}
if (groupConstrained) {
queryStringObj.group_constrained = true;
}