Mm 64299 disable guest invite in abac channels (#31139)
* MM-64299 - disable guest invite in abac channels * filter the abac channel list for guest * add filter in the back-end too * add proper translation * simplify the condition for enforced channels and add the unit tests * enhance validation for not inviting guest users when abac enforced channel * add missing translation * add value to empty translation * prevent showing the channel name if abac protected
Этот коммит содержится в:
@@ -1499,7 +1499,13 @@ func (a *App) prepareInviteGuestsToChannels(teamID string, guestsInvite *model.G
|
||||
if channel.TeamId != teamID {
|
||||
return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.channel_in_invalid_team.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Check if the channel has access control policy enforcement
|
||||
if channel.PolicyEnforced {
|
||||
return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.policy_enforced_channel.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
return user, team, channels, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1707,6 +1707,52 @@ func TestInviteGuestsToChannelsGracefully(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestInviteGuestsToChannelsWithPolicyEnforced(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableEmailInvitations = true
|
||||
})
|
||||
|
||||
// Create a private channel
|
||||
channel := th.CreatePrivateChannel(th.Context, th.BasicTeam)
|
||||
|
||||
// Create a policy with the same ID as the channel
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id, // Use the channel ID directly
|
||||
Name: "Test Channel Policy",
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Actions: []string{"view", "join_channel"},
|
||||
Expression: "user.attributes.program == \"test-program\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Save the channel policy
|
||||
channelPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, channelPolicy)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, channelPolicy)
|
||||
|
||||
// Attempt to invite guests to the policy-enforced channel
|
||||
guestsInvite := &model.GuestsInvite{
|
||||
Emails: []string{"guest@example.com"},
|
||||
Channels: []string{channel.Id},
|
||||
Message: "test message",
|
||||
}
|
||||
|
||||
// Call the function we want to test
|
||||
_, _, _, appErr := th.App.prepareInviteGuestsToChannels(th.BasicTeam.Id, guestsInvite, th.BasicUser.Id)
|
||||
|
||||
// Verify that the appropriate error is returned
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "api.team.invite_guests.policy_enforced_channel.app_error", appErr.Id)
|
||||
}
|
||||
|
||||
func TestTeamSendEvents(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
@@ -3230,6 +3230,10 @@
|
||||
"id": "api.team.invite_guests.channel_in_invalid_team.app_error",
|
||||
"translation": "The channels of the invite must be part of the team of the invite."
|
||||
},
|
||||
{
|
||||
"id": "api.team.invite_guests.policy_enforced_channel.app_error",
|
||||
"translation": "Cannot invite guests users to this channel because it has access restrictions based on user attributes."
|
||||
},
|
||||
{
|
||||
"id": "api.team.invite_guests_to_channels.disabled.error",
|
||||
"translation": "Guest accounts are disabled"
|
||||
|
||||
@@ -690,4 +690,31 @@ describe('components/channel_invite_modal', () => {
|
||||
// Should not call getProfilesNotInChannel after clearing the search
|
||||
expect(getProfilesNotInChannelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should hide the invite as guest link when channel has policy_enforced', () => {
|
||||
const channelWithPolicy = {
|
||||
...channel,
|
||||
policy_enforced: true,
|
||||
};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
channel: channelWithPolicy,
|
||||
canInviteGuests: true,
|
||||
emailInvitationsEnabled: true,
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<ChannelInviteModal {...props}/>,
|
||||
);
|
||||
|
||||
// Check that the invite as guest link is not shown
|
||||
const invitationLinks = wrapper.find('InviteModalLink');
|
||||
|
||||
// There should be no InviteModalLink with inviteAsGuest=true
|
||||
const guestInviteLinks = invitationLinks.findWhere(
|
||||
(node) => node.prop('inviteAsGuest') === true,
|
||||
);
|
||||
expect(guestInviteLinks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -506,7 +506,7 @@ const ChannelInviteModalComponent = (props: Props) => {
|
||||
props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE);
|
||||
};
|
||||
|
||||
const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => {
|
||||
const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string; abacChannelPolicyEnforced?: boolean}) => {
|
||||
return (
|
||||
<ToggleModalButton
|
||||
className={`${props.inviteAsGuest ? 'invite-as-guest' : ''} btn btn-link`}
|
||||
@@ -517,6 +517,7 @@ const ChannelInviteModalComponent = (props: Props) => {
|
||||
initialValue: term,
|
||||
inviteAsGuest: props.inviteAsGuest,
|
||||
focusOriginElement: 'customNoOptionsMessageLink',
|
||||
canInviteGuests: Boolean(!props.abacChannelPolicyEnforced),
|
||||
}}
|
||||
onClick={closeMembersInviteModal}
|
||||
id={props.id}
|
||||
@@ -535,7 +536,10 @@ const ChannelInviteModalComponent = (props: Props) => {
|
||||
defaultMessage='No matches found - <InvitationModalLink>Invite them to the team</InvitationModalLink>'
|
||||
values={{
|
||||
InvitationModalLink: (chunks: string) => (
|
||||
<InviteModalLink id='customNoOptionsMessageLink'>
|
||||
<InviteModalLink
|
||||
id='customNoOptionsMessageLink'
|
||||
abacChannelPolicyEnforced={props.channel.policy_enforced}
|
||||
>
|
||||
{chunks}
|
||||
</InviteModalLink>
|
||||
),
|
||||
@@ -647,7 +651,7 @@ const ChannelInviteModalComponent = (props: Props) => {
|
||||
teamId={channel.team_id}
|
||||
users={usersNotInTeam}
|
||||
/>
|
||||
{(props.emailInvitationsEnabled && props.canInviteGuests) && inviteGuestLink}
|
||||
{(props.emailInvitationsEnabled && props.canInviteGuests && !channel.policy_enforced) && inviteGuestLink}
|
||||
</div>
|
||||
</div>
|
||||
</GenericModal>
|
||||
|
||||
@@ -33,6 +33,7 @@ exports[`components/cloud_start_trial_btn/cloud_start_trial_btn should match sna
|
||||
}
|
||||
>
|
||||
<InviteAs
|
||||
canInviteGuests={true}
|
||||
inviteType="MEMBER"
|
||||
setInviteAs={[MockFunction]}
|
||||
titleClass="title"
|
||||
|
||||
@@ -70,11 +70,11 @@ export default function AddToChannels(props: Props) {
|
||||
|
||||
let placeholderChannelName = props.townSquareDisplayName;
|
||||
|
||||
// If the user is in a public or private channel,
|
||||
// If the user is in a public or private channel and is not abac policy enforced,
|
||||
// use this channel name as a placeholder.
|
||||
// Inviting to direct or group message channels
|
||||
// on a team is not currently supported.
|
||||
if (props.currentChannel && [Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(props.currentChannel.type)) {
|
||||
if (props.currentChannel && [Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(props.currentChannel.type) && !props.currentChannel.policy_enforced) {
|
||||
placeholderChannelName = props.currentChannel.display_name;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const searchChannels = (teamId: string, term: string) => {
|
||||
|
||||
type OwnProps = {
|
||||
channelToInvite?: Channel;
|
||||
canInviteGuests?: boolean;
|
||||
}
|
||||
|
||||
export function mapStateToProps(state: GlobalState, props: OwnProps) {
|
||||
@@ -72,7 +73,9 @@ export function mapStateToProps(state: GlobalState, props: OwnProps) {
|
||||
const emailInvitationsEnabled = config.EnableEmailInvitations === 'true';
|
||||
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
|
||||
const isGroupConstrained = Boolean(currentTeam?.group_constrained);
|
||||
const canInviteGuests = !isGroupConstrained && isEnterpriseReady && guestAccountsEnabled && haveICurrentTeamPermission(state, Permissions.INVITE_GUEST);
|
||||
const calculatedCanInviteGuests = !isGroupConstrained && isEnterpriseReady && guestAccountsEnabled && haveICurrentTeamPermission(state, Permissions.INVITE_GUEST);
|
||||
const canInviteGuests = props.canInviteGuests === undefined ? calculatedCanInviteGuests : (calculatedCanInviteGuests && props.canInviteGuests);
|
||||
|
||||
const isCloud = license.Cloud === 'true';
|
||||
|
||||
const canAddUsers = haveICurrentTeamPermission(state, Permissions.ADD_USER_TO_TEAM);
|
||||
|
||||
@@ -150,4 +150,79 @@ describe('InvitationModal', () => {
|
||||
|
||||
expect(wrapper.find(NoPermissionsView).length).toBe(1);
|
||||
});
|
||||
|
||||
it('filters out policy_enforced channels when inviting guests', async () => {
|
||||
// Create test channels with and without policy_enforced flag
|
||||
const regularChannel = TestHelper.getChannelMock({
|
||||
id: 'regular-channel',
|
||||
display_name: 'Regular Channel',
|
||||
name: 'regular-channel',
|
||||
policy_enforced: false,
|
||||
});
|
||||
|
||||
const policyEnforcedChannel = TestHelper.getChannelMock({
|
||||
id: 'policy-enforced-channel',
|
||||
display_name: 'Policy Enforced Channel',
|
||||
name: 'policy-enforced-channel',
|
||||
policy_enforced: true,
|
||||
});
|
||||
|
||||
props = {
|
||||
...props,
|
||||
invitableChannels: [regularChannel, policyEnforcedChannel],
|
||||
};
|
||||
|
||||
const wrapper = mountWithThemedIntl(
|
||||
<Provider store={store}>
|
||||
<InvitationModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Get the component instance with proper typing
|
||||
const instance = wrapper.find(InvitationModal).instance() as InvitationModal;
|
||||
|
||||
// Set invite type to GUEST
|
||||
instance.setState({
|
||||
invite: {
|
||||
...instance.state.invite,
|
||||
inviteType: 'GUEST',
|
||||
},
|
||||
});
|
||||
|
||||
// Call channelsLoader with empty search term
|
||||
const guestChannels = await instance.channelsLoader('');
|
||||
|
||||
// Verify only non-policy-enforced channels are returned for guests
|
||||
expect(guestChannels.length).toBe(1);
|
||||
expect(guestChannels[0].id).toBe('regular-channel');
|
||||
|
||||
// Set invite type to MEMBER
|
||||
instance.setState({
|
||||
invite: {
|
||||
...instance.state.invite,
|
||||
inviteType: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
// Call channelsLoader with empty search term
|
||||
const memberChannels = await instance.channelsLoader('');
|
||||
|
||||
// Verify all channels are returned for members
|
||||
expect(memberChannels.length).toBe(2);
|
||||
|
||||
// Test with search term
|
||||
instance.setState({
|
||||
invite: {
|
||||
...instance.state.invite,
|
||||
inviteType: 'GUEST',
|
||||
},
|
||||
});
|
||||
|
||||
// Call channelsLoader with search term that matches both channels
|
||||
const guestChannelsWithSearch = await instance.channelsLoader('channel');
|
||||
|
||||
// Verify only non-policy-enforced channels are returned for guests
|
||||
expect(guestChannelsWithSearch.length).toBe(1);
|
||||
expect(guestChannelsWithSearch[0].id).toBe('regular-channel');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,15 +262,39 @@ export default class InvitationModal extends React.PureComponent<Props, State> {
|
||||
|
||||
debouncedSearchChannels = debounce((term) => this.props.currentTeam && this.props.actions.searchChannels(this.props.currentTeam.id, term), 150);
|
||||
|
||||
// Filter channels based on the current invite type and search term
|
||||
filterChannels = (channels: Channel[], isGuestInvite: boolean, searchTerm: string = '') => {
|
||||
return channels.filter((channel) => {
|
||||
// For guest invites, filter out policy_enforced channels
|
||||
if (isGuestInvite && channel.policy_enforced) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there's a search term, filter by name match
|
||||
if (searchTerm) {
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
return channel.display_name.toLowerCase().includes(lowerSearchTerm) ||
|
||||
channel.name.toLowerCase().includes(lowerSearchTerm);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
channelsLoader = async (value: string) => {
|
||||
if (!value) {
|
||||
return this.props.invitableChannels;
|
||||
const isGuestInvite = this.state.invite.inviteType === InviteType.GUEST;
|
||||
|
||||
// If there's a search term, search the channels from the server
|
||||
if (value) {
|
||||
this.debouncedSearchChannels(value);
|
||||
}
|
||||
|
||||
this.debouncedSearchChannels(value);
|
||||
return this.props.invitableChannels.filter((channel) => {
|
||||
return channel.display_name.toLowerCase().startsWith(value.toLowerCase()) || channel.name.toLowerCase().startsWith(value.toLowerCase());
|
||||
});
|
||||
// Apply filtering to the channels
|
||||
return this.filterChannels(
|
||||
this.props.invitableChannels,
|
||||
isGuestInvite,
|
||||
value,
|
||||
);
|
||||
};
|
||||
|
||||
onChannelsChange = (channels: Channel[]) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('components/cloud_start_trial_btn/cloud_start_trial_btn', () => {
|
||||
setInviteAs: jest.fn(),
|
||||
inviteType: InviteType.MEMBER,
|
||||
titleClass: 'title',
|
||||
canInviteGuests: true,
|
||||
};
|
||||
|
||||
const state = {
|
||||
@@ -349,4 +350,167 @@ describe('components/cloud_start_trial_btn/cloud_start_trial_btn', () => {
|
||||
const badgeText = wrapper.find('.Tag span.tag-text').text();
|
||||
expect(badgeText).toBe('Professional feature');
|
||||
});
|
||||
|
||||
test('guest radio-button is disabled when canInviteGuests prop is false', () => {
|
||||
const propsWithCanInviteGuestsFalse = {
|
||||
...props,
|
||||
canInviteGuests: false,
|
||||
};
|
||||
|
||||
// Use a state where normally guests would be allowed (paid subscription)
|
||||
const paidState = {
|
||||
entities: {
|
||||
admin: {
|
||||
prevTrialLicense: {
|
||||
IsLicensed: 'true',
|
||||
},
|
||||
},
|
||||
general: {
|
||||
config: {
|
||||
BuildEnterpriseReady: 'true',
|
||||
},
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
SkuShortName: 'professional',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 0,
|
||||
sku: 'professional',
|
||||
product_id: 'cloud-professional-id',
|
||||
},
|
||||
products: {
|
||||
'cloud-professional-id': {
|
||||
sku: 'professional',
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {roles: 'system_admin'},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = mockStore(paidState);
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<InviteAs {...propsWithCanInviteGuestsFalse}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const guestRadioButton = wrapper.find('input[value="GUEST"]');
|
||||
expect(guestRadioButton.props().disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('guest radio-button is enabled when canInviteGuests prop is true and other conditions allow it', () => {
|
||||
const propsWithCanInviteGuestsTrue = {
|
||||
...props,
|
||||
canInviteGuests: true,
|
||||
};
|
||||
|
||||
// Use a state where guests would be allowed (paid subscription)
|
||||
const paidState = {
|
||||
entities: {
|
||||
admin: {
|
||||
prevTrialLicense: {
|
||||
IsLicensed: 'true',
|
||||
},
|
||||
},
|
||||
general: {
|
||||
config: {
|
||||
BuildEnterpriseReady: 'true',
|
||||
},
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
SkuShortName: 'professional',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 0,
|
||||
sku: 'professional',
|
||||
product_id: 'cloud-professional-id',
|
||||
},
|
||||
products: {
|
||||
'cloud-professional-id': {
|
||||
sku: 'professional',
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {roles: 'system_admin'},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = mockStore(paidState);
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<InviteAs {...propsWithCanInviteGuestsTrue}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const guestRadioButton = wrapper.find('input[value="GUEST"]');
|
||||
expect(guestRadioButton.props().disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('guest radio-button is disabled when canInviteGuests prop is undefined and defaults to system behavior', () => {
|
||||
// Test with starter plan where guests should be disabled by default
|
||||
const state = {
|
||||
entities: {
|
||||
admin: {
|
||||
prevTrialLicense: {
|
||||
IsLicensed: 'false',
|
||||
},
|
||||
},
|
||||
general: {
|
||||
config: {
|
||||
BuildEnterpriseReady: 'true',
|
||||
},
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
SkuShortName: CloudProducts.STARTER,
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 0,
|
||||
sku: CloudProducts.STARTER,
|
||||
product_id: 'cloud-starter-id',
|
||||
},
|
||||
products: {
|
||||
'cloud-starter-id': {
|
||||
sku: CloudProducts.STARTER,
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {roles: 'system_admin'},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<InviteAs {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const guestRadioButton = wrapper.find('input[value="GUEST"]');
|
||||
expect(guestRadioButton.props().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export type Props = {
|
||||
setInviteAs: (inviteType: InviteType) => void;
|
||||
inviteType: InviteType;
|
||||
titleClass?: string;
|
||||
canInviteGuests?: boolean;
|
||||
}
|
||||
|
||||
export default function InviteAs(props: Props) {
|
||||
@@ -138,7 +139,7 @@ export default function InviteAs(props: Props) {
|
||||
}
|
||||
|
||||
// disable the radio button logic (is disabled when is starter - pre and post trial)
|
||||
if (isStarter) {
|
||||
if (isStarter || !props.canInviteGuests) {
|
||||
guestDisabled = (id: string) => {
|
||||
return (id === InviteType.GUEST);
|
||||
};
|
||||
|
||||
@@ -240,6 +240,7 @@ export default function InviteView(props: Props) {
|
||||
inviteType={props.inviteType}
|
||||
setInviteAs={props.setInviteAs}
|
||||
titleClass='InviteView__sectionTitle'
|
||||
canInviteGuests={props.canInviteGuests}
|
||||
/>
|
||||
}
|
||||
{(props.inviteType === InviteType.GUEST || (props.inviteType === InviteType.MEMBER && props.channelToInvite)) && (
|
||||
|
||||
Ссылка в новой задаче
Block a user