diff --git a/server/channels/app/team.go b/server/channels/app/team.go
index 8f8466db60..1d6c13a6d3 100644
--- a/server/channels/app/team.go
+++ b/server/channels/app/team.go
@@ -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
}
diff --git a/server/channels/app/team_test.go b/server/channels/app/team_test.go
index 61ddf79899..47f8ee5594 100644
--- a/server/channels/app/team_test.go
+++ b/server/channels/app/team_test.go
@@ -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()
diff --git a/server/i18n/en.json b/server/i18n/en.json
index 34149e4bfb..0b3181088d 100644
--- a/server/i18n/en.json
+++ b/server/i18n/en.json
@@ -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"
diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx
index 5965814c3f..497093dc35 100644
--- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx
+++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx
@@ -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(
+ ,
+ );
+
+ // 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);
+ });
});
diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx
index 96b205a677..68958f6530 100644
--- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx
+++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx
@@ -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 (
{
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 - Invite them to the team'
values={{
InvitationModalLink: (chunks: string) => (
-
+
{chunks}
),
@@ -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}
diff --git a/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap b/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap
index 13394145be..692525e9f1 100644
--- a/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap
+++ b/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap
@@ -33,6 +33,7 @@ exports[`components/cloud_start_trial_btn/cloud_start_trial_btn should match sna
}
>
{
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);
diff --git a/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx b/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx
index 5529d042cb..f1e7cc23af 100644
--- a/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx
+++ b/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ // 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');
+ });
});
diff --git a/webapp/channels/src/components/invitation_modal/invitation_modal.tsx b/webapp/channels/src/components/invitation_modal/invitation_modal.tsx
index e877b9d957..299b3f113a 100644
--- a/webapp/channels/src/components/invitation_modal/invitation_modal.tsx
+++ b/webapp/channels/src/components/invitation_modal/invitation_modal.tsx
@@ -262,15 +262,39 @@ export default class InvitationModal extends React.PureComponent {
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[]) => {
diff --git a/webapp/channels/src/components/invitation_modal/invite_as.test.tsx b/webapp/channels/src/components/invitation_modal/invite_as.test.tsx
index 2d65112fd6..5d94a6aa1a 100644
--- a/webapp/channels/src/components/invitation_modal/invite_as.test.tsx
+++ b/webapp/channels/src/components/invitation_modal/invite_as.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ const guestRadioButton = wrapper.find('input[value="GUEST"]');
+ expect(guestRadioButton.props().disabled).toBe(true);
+ });
});
diff --git a/webapp/channels/src/components/invitation_modal/invite_as.tsx b/webapp/channels/src/components/invitation_modal/invite_as.tsx
index 1af0f59e87..04ba324c3c 100644
--- a/webapp/channels/src/components/invitation_modal/invite_as.tsx
+++ b/webapp/channels/src/components/invitation_modal/invite_as.tsx
@@ -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);
};
diff --git a/webapp/channels/src/components/invitation_modal/invite_view.tsx b/webapp/channels/src/components/invitation_modal/invite_view.tsx
index b43240e4c5..0c5046360f 100644
--- a/webapp/channels/src/components/invitation_modal/invite_view.tsx
+++ b/webapp/channels/src/components/invitation_modal/invite_view.tsx
@@ -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)) && (