MM-64428 - user tag invite filtering (#31226)

* MM-64428 - user tag invite filtering

* fix lint issues

* remove unnecesary line

* update translations and skip mysql tests

* simplify the solution so in abac channels the invitation link is never shown

* finish clean up of unnecessary code

* clean up and remove no longer necessary translations

* remove leftover props and remove no longer needed tests after simplification

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Pablo Vélez 2025-07-02 14:55:02 +02:00 committed by GitHub
parent f1893e4837
commit e99aa4e430
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 151 additions and 43 deletions

View file

@ -1260,6 +1260,7 @@ func (a *App) filterOutOfChannelMentions(c request.CTX, sender *model.User, post
// Differentiate between mentionedUsersInTheTeam who can and can't be added to the channel
var outOfChannelUsers model.UserSlice
var outOfGroupsUsers model.UserSlice
if channel.IsGroupConstrained() {
nonMemberIDs, err := a.FilterNonGroupChannelMembers(teamUsers.IDs(), channel)
if err != nil {
@ -1289,6 +1290,8 @@ func makeOutOfChannelMentionPost(sender *model.User, post *model.Post, outOfChan
ephemeralPostId := model.NewId()
var message string
// Generate message for users who can be invited
if len(outOfChannelUsers) == 1 {
message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]any{
"Username": ocUsernames[0],

View file

@ -6,7 +6,9 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, empty
exports[`components/post_view/PostAddChannelMember should match snapshot, more than 3 users 1`] = `
<Fragment>
<p>
<p
key="invitable"
>
<span>
<Connect(Component)
channelId="channel_id"
@ -67,7 +69,9 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
exports[`components/post_view/PostAddChannelMember should match snapshot, more than 3 users 2`] = `
<Fragment>
<p>
<p
key="invitable"
>
<span>
<Connect(Component)
channelId="channel_id"
@ -129,7 +133,9 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
exports[`components/post_view/PostAddChannelMember should match snapshot, private channel 1`] = `
<Fragment>
<p>
<p
key="invitable"
>
<Connect(Component)
channelId="channel_id"
mentionName="username_1"
@ -158,7 +164,9 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, privat
exports[`components/post_view/PostAddChannelMember should match snapshot, public channel 1`] = `
<Fragment>
<p>
<p
key="invitable"
>
<Connect(Component)
channelId="channel_id"
mentionName="username_1"
@ -185,9 +193,45 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, public
</Fragment>
`;
exports[`components/post_view/PostAddChannelMember should match snapshot, with ABAC policy enforced 1`] = `
<p>
<span>
<Connect(Component)
channelId="channel_id"
key="username_1"
mentionName="username_1"
/>
<span
key="1"
>
,
</span>
<Connect(Component)
channelId="channel_id"
key="username_2"
mentionName="username_2"
/>
<MemoizedFormattedMessage
defaultMessage=" and "
id="post_body.check_for_out_of_channel_mentions.link.and"
key="2"
/>
<Connect(Component)
channelId="channel_id"
key="username_3"
mentionName="username_3"
/>
</span>
did not get notified by this mention because they are not in the channel.
</p>
`;
exports[`components/post_view/PostAddChannelMember should match snapshot, with no-groups usernames 1`] = `
<Fragment>
<p>
<p
key="invitable"
>
<Connect(Component)
channelId="channel_id"
mentionName="username_1"
@ -211,7 +255,9 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, with n
id="post_body.check_for_out_of_channel_mentions.message_last"
/>
</p>
<p>
<p
key="out-of-groups"
>
<Connect(Component)
channelId="channel_id"
mentionName="user_id_2"

View file

@ -17,15 +17,18 @@ import PostAddChannelMember from './post_add_channel_member';
type OwnProps = {
postId: string;
userIds: string[];
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const post = getPost(state, ownProps.postId) || {};
let channelType = '';
let isPolicyEnforced = false;
if (post && post.channel_id) {
const channel = getChannel(state, post.channel_id);
if (channel && channel.type) {
channelType = channel.type;
isPolicyEnforced = Boolean(channel.policy_enforced);
}
}
@ -33,6 +36,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
channelType,
currentUser: getCurrentUser(state),
post,
isPolicyEnforced,
};
}

View file

@ -43,6 +43,7 @@ describe('components/post_view/PostAddChannelMember', () => {
addChannelMember: jest.fn(),
},
noGroupsUsernames: [],
isPolicyEnforced: false,
};
test('should match snapshot, empty postId', () => {
@ -140,4 +141,39 @@ describe('components/post_view/PostAddChannelMember', () => {
const wrapper = shallow(<PostAddChannelMember {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, with ABAC policy enforced', () => {
const props: Props = {
...requiredProps,
usernames: ['username_1', 'username_2', 'username_3'],
isPolicyEnforced: true,
};
const wrapper = shallow(<PostAddChannelMember {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should never show invite links when policy is enforced (ABAC channels)', () => {
const props: Props = {
...requiredProps,
usernames: ['username_1', 'username_2'],
noGroupsUsernames: [],
isPolicyEnforced: true,
};
const wrapper = shallow(<PostAddChannelMember {...props}/>);
expect(wrapper.find('.PostBody_addChannelMemberLink')).toHaveLength(0);
});
test('should show single consolidated message for ABAC channels regardless of user types', () => {
const props: Props = {
...requiredProps,
usernames: ['user1', 'user2'],
noGroupsUsernames: ['user3'],
isPolicyEnforced: true,
};
const wrapper = shallow(<PostAddChannelMember {...props}/>);
// Should render only one consolidated message with no invite links
expect(wrapper.find('p')).toHaveLength(1);
expect(wrapper.find('.PostBody_addChannelMemberLink')).toHaveLength(0);
});
});

View file

@ -26,6 +26,7 @@ export interface Props {
userIds: string[];
usernames: string[];
noGroupsUsernames: string[];
isPolicyEnforced: boolean;
actions: Actions;
}
@ -146,11 +147,33 @@ export default class PostAddChannelMember extends React.PureComponent<Props, Sta
}
render() {
const {channelType, postId, usernames, noGroupsUsernames} = this.props;
const {channelType, postId, usernames, noGroupsUsernames, isPolicyEnforced} = this.props;
if (!postId || !channelType) {
return null;
}
// For ABAC channels (policy enforced), NEVER show invite links - only show notification message
if (isPolicyEnforced) {
// Combine all users into a single message without any invite functionality
const allUsers = [...usernames, ...noGroupsUsernames];
const allUsersAtMentions = this.generateAtMentions(allUsers);
if (allUsers.length === 0) {
return null;
}
const messageText = 'did not get notified by this mention because they are not in the channel.';
return (
<p>
{allUsersAtMentions}
{' '}
{messageText}
</p>
);
}
// Regular flow for non-ABAC channels
let link;
if (channelType === Constants.PRIVATE_CHANNEL) {
link = (
@ -168,44 +191,32 @@ export default class PostAddChannelMember extends React.PureComponent<Props, Sta
);
}
let outOfChannelMessagePart;
const outOfChannelAtMentions = this.generateAtMentions(usernames);
if (usernames.length === 1) {
outOfChannelMessagePart = (
// Separate invitable users from group-constrained users
const invitableUsers = usernames.filter((username) => !noGroupsUsernames.includes(username));
const outOfGroupsUsers = noGroupsUsernames;
const messages = [];
// Handle invitable users with invite functionality
if (invitableUsers.length > 0) {
const invitableAtMentions = this.generateAtMentions(invitableUsers);
const invitableMessagePart = invitableUsers.length === 1 ? (
<FormattedMessage
id='post_body.check_for_out_of_channel_mentions.message.one'
defaultMessage='did not get notified by this mention because they are not in the channel. Would you like to '
/>
);
} else if (usernames.length > 1) {
outOfChannelMessagePart = (
) : (
<FormattedMessage
id='post_body.check_for_out_of_channel_mentions.message.multiple'
defaultMessage='did not get notified by this mention because they are not in the channel. Would you like to '
/>
);
}
let outOfGroupsMessagePart;
const outOfGroupsAtMentions = this.generateAtMentions(noGroupsUsernames);
if (noGroupsUsernames.length) {
outOfGroupsMessagePart = (
<FormattedMessage
id='post_body.check_for_out_of_channel_groups_mentions.message'
defaultMessage='did not get notified by this mention because they are not in the channel. They cannot be added to the channel because they are not a member of the linked groups. To add them to this channel, they must be added to the linked groups.'
/>
);
}
let outOfChannelMessage = null;
let outOfGroupsMessage = null;
if (usernames.length) {
outOfChannelMessage = (
<p>
{outOfChannelAtMentions}
messages.push(
<p key='invitable'>
{invitableAtMentions}
{' '}
{outOfChannelMessagePart}
{invitableMessagePart}
<a
className='PostBody_addChannelMemberLink'
onClick={this.handleAddChannelMember}
@ -213,27 +224,35 @@ export default class PostAddChannelMember extends React.PureComponent<Props, Sta
{link}
</a>
<FormattedMessage
id={'post_body.check_for_out_of_channel_mentions.message_last'}
defaultMessage={'? They will have access to all message history.'}
id='post_body.check_for_out_of_channel_mentions.message_last'
defaultMessage='? They will have access to all message history.'
/>
</p>
</p>,
);
}
if (noGroupsUsernames.length) {
outOfGroupsMessage = (
<p>
// Handle users not in required groups with specific messaging
if (outOfGroupsUsers.length > 0) {
const outOfGroupsAtMentions = this.generateAtMentions(outOfGroupsUsers);
const outOfGroupsMessagePart = (
<FormattedMessage
id='post_body.check_for_out_of_channel_groups_mentions.message'
defaultMessage='did not get notified by this mention because they are not in the channel. They cannot be added to the channel because they are not a member of the linked groups. To add them to this channel, they must be added to the linked groups.'
/>
);
messages.push(
<p key='out-of-groups'>
{outOfGroupsAtMentions}
{' '}
{outOfGroupsMessagePart}
</p>
</p>,
);
}
return (
<>
{outOfChannelMessage}
{outOfGroupsMessage}
{messages}
</>
);
}