diff --git a/docs/main/administration-guide/upgrade/upgrade-v11.mdx b/docs/main/administration-guide/upgrade/upgrade-v11.mdx
index 71d3cb221c35..ca543e1996d8 100644
--- a/docs/main/administration-guide/upgrade/upgrade-v11.mdx
+++ b/docs/main/administration-guide/upgrade/upgrade-v11.mdx
@@ -10,7 +10,7 @@ slug: /upgrade-guide/upgrade-v11
# Upgrade Guide v11
-Per-major-version upgrade guides give operators a single canonical procedure for moving a Mattermost cluster to that major version. They are paired with the corresponding [What's New in v11](../../product-overview/whats-new-in-v11.mdx) page (release-management facing) — both share the same source-of-truth for breaking changes, but this page focuses on the operator runbook.
+Per-major-version upgrade guides give operators a single canonical procedure for moving a Mattermost cluster to that major version. This page focuses on the operator runbook; see the [v11 Changelog](../../product-overview/mattermost-v11-changelog.mdx) for commit-level release notes.
URL convention: `/upgrade-guide/upgrade-v{X.Y}/` (Grafana pattern). Predictable, deep-linkable, per-version.
@@ -38,7 +38,6 @@ This page is a stub seeded by the IA redesign Phase 1 (see `docs/_redesign/propo
## Related
-- [What's New in v11](../../product-overview/whats-new-in-v11.mdx) — feature and behavior changes.
- [v11 Changelog](../../product-overview/mattermost-v11-changelog.mdx) — commit-level release notes.
- [Important Upgrade Notes](/administration-guide/upgrade/important-upgrade-notes) — version-by-version notes for non-sequential upgrades.
- [Upgrading Mattermost Server](./upgrading-mattermost-server.mdx) — general upgrade procedure (cross-version).
diff --git a/docs/main/product-overview/whats-new-in-v11.mdx b/docs/main/product-overview/whats-new-in-v11.mdx
deleted file mode 100644
index 4c417f738e8c..000000000000
--- a/docs/main/product-overview/whats-new-in-v11.mdx
+++ /dev/null
@@ -1,42 +0,0 @@
----
-title: What's New in v11
-sidebar_position: 50
-description: Highlights, new features, deprecations, and breaking changes in Mattermost v11.
-slug: /whats-new/whats-new-in-v11
----
-
-
-
-
-# What's New in v11
-
-Per-major-version "What's New" pages summarize highlights, new features, deprecations, and breaking changes for that major release. Detailed commit-level history lives in the [v11 Changelog](./mattermost-v11-changelog.mdx).
-
-URL convention: `/whats-new/whats-new-in-v{X.Y}/` (Grafana pattern). Each major version gets its own page with a stable, deep-linkable URL.
-
-:::note Status
-This page is a stub seeded by the IA redesign Phase 1 (see `docs/_redesign/proposed-ia.md` §6 in the repo). Content authored by the release-management team.
-:::
-
-## Highlights
-
-*Per-release highlights authored by release-management team.*
-
-## New features
-
-*Per-release feature list. Each entry should link to the canonical feature page in the End User Guide, Administration Guide, or Integrations Guide.*
-
-## Deprecations
-
-*Features marked deprecated in this major. Each entry should link to the [Deprecated Features](./deprecated-features.mdx) registry.*
-
-## Breaking changes
-
-*Behaviors changed in a backwards-incompatible way. Each entry should cross-link to the [Upgrade Guide v11](../administration-guide/upgrade/upgrade-v11.mdx) so operators see the same information from the operator-facing angle.*
-
-## Related
-
-- [v11 Changelog](./mattermost-v11-changelog.mdx) — commit-level release notes.
-- [Upgrade Guide v11](../administration-guide/upgrade/upgrade-v11.mdx) — operator-facing upgrade procedure.
-- [Important Upgrade Notes](/administration-guide/upgrade/important-upgrade-notes) — version-by-version notes for non-sequential upgrades.
-- [Release Policy](./release-policy.mdx) — Mattermost's ESR cadence and support windows.
diff --git a/docs/site/scripts/gen-documentation-sidebar.mjs b/docs/site/scripts/gen-documentation-sidebar.mjs
index 69ec763e268f..b64313932586 100644
--- a/docs/site/scripts/gen-documentation-sidebar.mjs
+++ b/docs/site/scripts/gen-documentation-sidebar.mjs
@@ -142,8 +142,6 @@ const OVERVIEW_ROOT_ORDER = [
'accessibility-compliance-policy',
{group: 'releases'},
{group: 'faq'},
- // Locally-added pages not present on live:
- 'whats-new-in-v11',
];
// ---------------------------------------------------------------------------
diff --git a/docs/site/src/css/custom.css b/docs/site/src/css/custom.css
index aa8e98cd14e1..1748afe34c04 100644
--- a/docs/site/src/css/custom.css
+++ b/docs/site/src/css/custom.css
@@ -96,17 +96,39 @@ body {
/* Headlines target Trade Gothic Next Heavy proportions: tighter set
* than Archivo Black's default. We tune letter-spacing per size and
* apply a mild horizontal squeeze via font-stretch where the platform
- * supports it (no-op on Archivo Black, which is fixed-width). */
-h1, h2, h3, h4 {
+ * supports it (no-op on Archivo Black, which is fixed-width).
+ *
+ * The heavy display face is reserved for h1 page titles. h2-h4 are
+ * in-body section headers, not hero copy, so they use the regular
+ * sans family at a normal bold weight instead of the 900-weight
+ * poster face — otherwise every subheading reads as loud as the
+ * page title. */
+h1 {
font-family: var(--mm-font-heading);
- letter-spacing: -0.015em;
+ font-weight: 900;
+ letter-spacing: -0.025em;
line-height: 1.12;
font-stretch: 92%; /* ignored by static fonts; helps with variable Saira if loaded */
}
-h1 { letter-spacing: -0.025em; }
-h2 { letter-spacing: -0.018em; }
-h3 { letter-spacing: -0.012em; }
+.markdown h2, .markdown h3, .markdown h4,
+h2, h3, h4 {
+ font-family: var(--mm-font-sans);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1.25;
+}
+
+/* Search results page ("See all results") — each hit's title is a plain h2
+ * outside `.markdown`, so it inherits the full --ifm-h2-font-size (1.85rem),
+ * as large as an in-article section header. Scoped to the wrapper class
+ * Docusaurus's SearchPage puts on rather than the plugin's own
+ * (hashed) CSS-module class, so this survives dependency upgrades. */
+.search-page-wrapper h2 {
+ font-size: 1.15rem;
+ font-weight: 600;
+ letter-spacing: -0.005em;
+}
/* Section heading rhythm — a small marigold rule sits above each h2.
* Scoped via the `mm-section` class (auto-applied below) so it doesn't
diff --git a/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts b/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts
index 072216c68963..a705c6af1395 100644
--- a/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts
+++ b/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts
@@ -73,6 +73,21 @@ export async function createParentPolicy(client: Client4, name: string) {
});
}
+// Parent policy with a real membership expression, assignable to a team (v0.3).
+export async function createParentMembershipPolicy(client: Client4, name: string, expression: string) {
+ return (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies`, {
+ method: 'put',
+ body: JSON.stringify({
+ id: '',
+ name,
+ type: 'parent',
+ version: 'v0.3',
+ revision: 0,
+ rules: [{expression, actions: ['membership']}],
+ }),
+ });
+}
+
export async function assignChannelsToPolicy(client: Client4, policyId: string, channelIds: string[]) {
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}/assign`;
const response = await fetch(url, {
diff --git a/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_discoverability.spec.ts b/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_discoverability.spec.ts
index 14e2fa2d5607..d3b43d6e45d3 100644
--- a/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_discoverability.spec.ts
+++ b/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_discoverability.spec.ts
@@ -14,6 +14,8 @@ import {
createPublicTeam,
createPrivateTeam,
createTeamMembershipPolicy,
+ createParentMembershipPolicy,
+ assignTeamToParentPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from './helpers';
@@ -181,33 +183,37 @@ test.describe('Team Settings Modal - Access Tab - Discoverability', {tag: ['@aba
await teamSettings.close();
});
- test('MM-69100_4 team with active ABAC policy shows disabled cards with policy notice', async ({pw}) => {
+ test('MM-69100_4 active ABAC policy leaves cards enabled and mode-flip reachable', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableTeamMembershipABACConfig(adminClient);
- // initSetup creates type='O' but allow_open_invite=false; make the team
- // fully public so isPublicTeam=true and the disabled logic activates.
+ // # Make the team fully public
await adminClient.patchTeam({id: team.id, allow_open_invite: true} as any);
- // # Create a team membership policy with auto-add ON (makes team policy_enforced + policy_is_active)
+ // # Attach a policy with auto-add on
await createTeamMembershipPolicy(adminClient, team.id, 'true', true);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
- // # Navigate and open Team Settings (team fetched with policy_enforced=true)
+ // # Navigate and open Team Settings
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const teamSettings = await channelsPage.openTeamSettings();
await teamSettings.openAccessTab();
- // * Policy notice is visible
- await expect(teamSettings.container.getByText(/This team's membership is managed by a policy/i)).toBeVisible();
+ // * No "managed by a policy" notice is shown
+ await expect(teamSettings.container.getByText(/managed by a policy/i)).toHaveCount(0);
+
+ // * Neither card carries the disabled CSS class
+ await expect(teamSettings.container.locator('#public-private-selector-button-O')).not.toHaveClass(/disabled/);
+ await expect(teamSettings.container.locator('#public-private-selector-button-P')).not.toHaveClass(/disabled/);
- // * Cards have the disabled CSS class (not HTML disabled — clicks are suppressed in JS)
- await expect(teamSettings.container.locator('#public-private-selector-button-O')).toHaveClass(/disabled/);
- await expect(teamSettings.container.locator('#public-private-selector-button-P')).toHaveClass(/disabled/);
+ // # Clicking Private opens the mode-flip confirmation (card is not blocked)
+ await teamSettings.container.locator('#public-private-selector-button-P').click();
+ const modeFlipModal = page.locator('.ConfirmModal').filter({hasText: 'Switch to Private Team?'});
+ await expect(modeFlipModal).toBeVisible({timeout: 30000});
await teamSettings.close();
});
@@ -352,4 +358,106 @@ test.describe('Team Settings Modal - Access Tab - Discoverability', {tag: ['@aba
await teamSettings.close();
});
+
+ test('MM-69100_40 governed team switched public can be switched back to private', async ({pw}) => {
+ await pw.skipIfNoLicense();
+ const {adminClient, adminUser} = await pw.getAdminClient();
+ if (!adminUser) {
+ throw new Error('Admin user not found');
+ }
+ const suffix = pw.random.id();
+ await enableTeamMembershipABACConfig(adminClient);
+
+ // # Private team with an auto-add-on policy
+ const team = await createPrivateTeam(adminClient, suffix);
+ await createTeamMembershipPolicy(adminClient, team.id, 'true', true);
+
+ const {page} = await pw.testBrowser.login(adminUser);
+ const channelsPage = new ChannelsPage(page);
+ await channelsPage.goto(team.name, 'town-square');
+ await channelsPage.toBeVisible();
+
+ // # Switch the team public and save
+ let teamSettings = await channelsPage.openTeamSettings();
+ await teamSettings.openAccessTab();
+ await teamSettings.container.locator('#public-private-selector-button-O').click();
+ await teamSettings.save();
+ await teamSettings.verifySavedMessage();
+ await teamSettings.close();
+
+ // # Reopen Team Settings
+ teamSettings = await channelsPage.openTeamSettings();
+ await teamSettings.openAccessTab();
+
+ // * Cards are not disabled
+ await expect(teamSettings.container.locator('#public-private-selector-button-O')).not.toHaveClass(/disabled/);
+ await expect(teamSettings.container.locator('#public-private-selector-button-P')).not.toHaveClass(/disabled/);
+
+ // # Switch back to private via the mode-flip modal
+ await teamSettings.container.locator('#public-private-selector-button-P').click();
+ const modeFlipModal = page.locator('.ConfirmModal').filter({hasText: 'Switch to Private Team?'});
+ await expect(modeFlipModal).toBeVisible({timeout: 30000});
+ await modeFlipModal.getByRole('button', {name: 'Switch to Private'}).click();
+ await expect(modeFlipModal).not.toBeVisible({timeout: 5000});
+ await teamSettings.save();
+ await teamSettings.verifySavedMessage();
+
+ // * Team is private again
+ const updatedTeam = await adminClient.getTeam(team.id);
+ expect(updatedTeam.allow_open_invite).toBe(false);
+
+ await teamSettings.close();
+ });
+
+ test('MM-69100_41 mode-flip modal shows member count for a parent-policy team', async ({pw}) => {
+ await pw.skipIfNoLicense();
+ const {adminClient, adminUser} = await pw.getAdminClient();
+ if (!adminUser) {
+ throw new Error('Admin user not found');
+ }
+ const suffix = pw.random.id();
+ const expression = 'user.attributes.Department == "Engineering"';
+
+ await enableTeamMembershipABACConfig(adminClient);
+ await ensureDepartmentAttribute(adminClient);
+
+ // # Public team; admin qualifies, added member does not
+ const team = await createPublicTeam(adminClient, suffix);
+ await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
+
+ const mkt = await adminClient.createUser(
+ {
+ email: `testuser${suffix}mkt@sample.mattermost.com`,
+ username: `testuser${suffix}mkt`,
+ password: newTestPassword(),
+ } as any,
+ '',
+ '',
+ );
+ await adminClient.addToTeam(team.id, mkt.id);
+ await setUserAttribute(adminClient, mkt.id, 'Department', 'Marketing');
+
+ // # Govern via a parent policy (child imports it, no own rules)
+ const parent: any = await createParentMembershipPolicy(adminClient, `eng-parent-${suffix}`, expression);
+ await assignTeamToParentPolicy(adminClient, parent.id, team.id);
+
+ await waitForAttributeViewToInclude(adminClient, expression, [adminUser.id]);
+
+ const {page} = await pw.testBrowser.login(adminUser);
+ const channelsPage = new ChannelsPage(page);
+ await channelsPage.goto(team.name, 'town-square');
+ await channelsPage.toBeVisible();
+ const teamSettings = await channelsPage.openTeamSettings();
+ await teamSettings.openAccessTab();
+
+ // # Click Private → mode-flip modal
+ await teamSettings.container.locator('#public-private-selector-button-P').click();
+ const modeFlipModal = page.locator('.ConfirmModal').filter({hasText: 'Switch to Private Team?'});
+ await expect(modeFlipModal).toBeVisible({timeout: 30000});
+
+ // * Modal shows the resolved count, not the generic message
+ await expect(modeFlipModal.getByText(/1 current member does not meet/i)).toBeVisible({timeout: 10000});
+
+ await teamSettings.close();
+ });
});
diff --git a/server/Makefile b/server/Makefile
index 298db18c2a96..1908fcb47380 100644
--- a/server/Makefile
+++ b/server/Makefile
@@ -164,7 +164,7 @@ PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.11.0
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.13.0
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.5.0-rc1
-PLUGIN_PACKAGES += mattermost-plugin-boards-v9.3.0
+PLUGIN_PACKAGES += mattermost-plugin-boards-v9.3.1
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.7.0
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.1
@@ -178,7 +178,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0
ifeq ($(FIPS_ENABLED),true)
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.11.0%2B931852a-fips
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.5.0-rc1%2B666ae7a-fips
- PLUGIN_PACKAGES += mattermost-plugin-boards-v9.3.0%2Bd454327-fips
+ PLUGIN_PACKAGES += mattermost-plugin-boards-v9.3.1%2B29b4688-fips
endif
EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx
index 5b1f3cd06430..6ffb4a7a0688 100644
--- a/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx
+++ b/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx
@@ -6,7 +6,7 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {AccessControlPolicy, AccessControlPolicyRule} from '@mattermost/types/access_control';
-import {getMembershipRule, buildRulesWithMembership} from '@mattermost/types/access_control';
+import {getMembershipRule, buildRulesWithMembership, combineMembershipExpressions} from '@mattermost/types/access_control';
import {SyncableType} from '@mattermost/types/groups';
import type {Group, SyncablePatch} from '@mattermost/types/groups';
import type {UserPropertyField} from '@mattermost/types/properties_user';
@@ -802,28 +802,11 @@ export default class TeamDetails extends React.PureComponent {
hideArchiveConfirmModal = () => this.setState({showArchiveConfirmModal: false});
- // Combine the team's own membership expression with the expressions of any
- // linked parent policies, ANDed together. The ad-hoc searchUsersForExpression
- // endpoint does NOT resolve imports server-side, so the effective expression
- // must be assembled here (mirrors the sync job and the channel access-rules tab).
private combineTeamAndPolicyExpressions = (teamExpression: string): string => {
const parentExpressions = this.state.accessControlPolicies.
- map((policy) => getMembershipRule(policy.rules)?.expression).
- filter((expr): expr is string => Boolean(expr && expr.trim()));
+ map((policy) => getMembershipRule(policy.rules)?.expression);
- const allExpressions: string[] = [];
- if (teamExpression.trim()) {
- allExpressions.push(teamExpression.trim());
- }
- allExpressions.push(...parentExpressions);
-
- if (allExpressions.length === 0) {
- return '';
- }
- if (allExpressions.length === 1) {
- return allExpressions[0]!;
- }
- return allExpressions.map((expr) => `(${expr})`).join(' && ');
+ return combineMembershipExpressions([teamExpression, ...parentExpressions]);
};
onSave = async () => {
diff --git a/webapp/channels/src/components/team_settings/team_access_tab/index.ts b/webapp/channels/src/components/team_settings/team_access_tab/index.ts
index 436a1b1c4aee..70b76c33f112 100644
--- a/webapp/channels/src/components/team_settings/team_access_tab/index.ts
+++ b/webapp/channels/src/components/team_settings/team_access_tab/index.ts
@@ -10,6 +10,7 @@ import type {Team} from '@mattermost/types/teams';
import {
createAccessControlTeamSyncJob,
+ getAccessControlPolicy,
getTeamAccessControlPolicy,
searchUsersForExpression,
} from 'mattermost-redux/actions/access_control';
@@ -42,6 +43,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
regenerateTeamInviteId,
getTeamStats,
getTeamAccessControlPolicy,
+ getAccessControlPolicy,
searchUsersForExpression,
createAccessControlTeamSyncJob,
}, dispatch),
diff --git a/webapp/channels/src/components/team_settings/team_access_tab/open_invite.test.tsx b/webapp/channels/src/components/team_settings/team_access_tab/open_invite.test.tsx
index 49a8962eda70..6f614f90c7a8 100644
--- a/webapp/channels/src/components/team_settings/team_access_tab/open_invite.test.tsx
+++ b/webapp/channels/src/components/team_settings/team_access_tab/open_invite.test.tsx
@@ -12,7 +12,6 @@ describe('components/TeamSettings/OpenInvite', () => {
const defaultProps: ComponentProps = {
isPublic: false,
isGroupConstrained: false,
- policyEnforced: false,
onChange,
};
@@ -57,46 +56,19 @@ describe('components/TeamSettings/OpenInvite', () => {
expect(onChange).toHaveBeenCalledWith(false);
});
- test('shows policy-enforced notice and disables cards when policyEnforced and policyIsActive are true on a public team', () => {
+ test('never disables cards or shows a policy notice on a public team', async () => {
renderWithContext(
,
);
- expect(screen.getByText(/membership is managed by a policy/i)).toBeInTheDocument();
+ expect(screen.queryByText(/membership is managed by a policy/i)).not.toBeInTheDocument();
const publicBtn = screen.getByRole('button', {name: /public team/i});
const privateBtn = screen.getByRole('button', {name: /private team/i});
- expect(publicBtn.className).toMatch(/disabled/);
- expect(privateBtn.className).toMatch(/disabled/);
- });
-
- test('does not disable cards when policyEnforced but policyIsActive is false', async () => {
- renderWithContext(
- ,
- );
- expect(screen.queryByText(/membership is managed by a policy/i)).not.toBeInTheDocument();
+ expect(publicBtn.className).not.toMatch(/disabled/);
+ expect(privateBtn.className).not.toMatch(/disabled/);
await userEvent.click(screen.getByText('Private Team'));
expect(onChange).toHaveBeenCalledWith(false);
});
-
- test('does not disable cards on a private team even when policyIsActive is true', async () => {
- renderWithContext(
- ,
- );
- await userEvent.click(screen.getByText('Public Team'));
- expect(onChange).toHaveBeenCalledWith(true);
- });
});
diff --git a/webapp/channels/src/components/team_settings/team_access_tab/open_invite.tsx b/webapp/channels/src/components/team_settings/team_access_tab/open_invite.tsx
index a1b11d03fe4b..f4da92343a7f 100644
--- a/webapp/channels/src/components/team_settings/team_access_tab/open_invite.tsx
+++ b/webapp/channels/src/components/team_settings/team_access_tab/open_invite.tsx
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
-import {FormattedMessage, useIntl} from 'react-intl';
+import {useIntl} from 'react-intl';
import ExternalLink from 'components/external_link';
import BaseSettingItem from 'components/widgets/modals/components/base_setting_item';
@@ -13,12 +13,10 @@ import {Constants} from 'utils/constants';
type Props = {
isPublic: boolean;
isGroupConstrained?: boolean;
- policyEnforced?: boolean;
- policyIsActive?: boolean;
onChange: (isPublic: boolean) => void;
};
-const OpenInvite = ({isPublic, isGroupConstrained, policyEnforced, policyIsActive, onChange}: Props) => {
+const OpenInvite = ({isPublic, isGroupConstrained, onChange}: Props) => {
const {formatMessage} = useIntl();
if (isGroupConstrained) {
@@ -56,20 +54,6 @@ const OpenInvite = ({isPublic, isGroupConstrained, policyEnforced, policyIsActiv
);
}
- // Only lock the cards and show the "managed" notice when auto-add is actively managing membership.
- // When the policy exists but auto-add is off, cards remain clickable so the mode-flip flow can run.
- // On a private team the public card is never locked: switching to public reduces restriction and is always allowed.
- const cardsDisabled = Boolean(policyIsActive) && isPublic;
-
- const policyNotice = (policyEnforced && policyIsActive) ? (
-
-
-
- ) : null;
-
const handleChange = (selected: string) => {
onChange(selected === Constants.OPEN_CHANNEL);
};
@@ -87,11 +71,6 @@ const OpenInvite = ({isPublic, isGroupConstrained, policyEnforced, policyIsActiv
id: 'team_settings.discoverability.public_description',
defaultMessage: 'Anyone on the server can find and join',
}),
- disabled: cardsDisabled,
- tooltip: cardsDisabled ? formatMessage({
- id: 'team_settings.discoverability.policy_enforced_tooltip',
- defaultMessage: 'Membership is managed by a policy',
- }) : undefined,
}}
privateButtonProps={{
title: formatMessage({
@@ -102,15 +81,9 @@ const OpenInvite = ({isPublic, isGroupConstrained, policyEnforced, policyIsActiv
id: 'team_settings.discoverability.private_description',
defaultMessage: 'Only invited members can join',
}),
- disabled: cardsDisabled,
- tooltip: cardsDisabled ? formatMessage({
- id: 'team_settings.discoverability.policy_enforced_tooltip',
- defaultMessage: 'Membership is managed by a policy',
- }) : undefined,
}}
onChange={handleChange}
/>
- {policyNotice}
);
diff --git a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.scss b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.scss
index 9d735ccdc2ff..77ff44c9b039 100644
--- a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.scss
+++ b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.scss
@@ -39,11 +39,4 @@
flex-direction: column;
gap: 12px;
}
-
- &__policyEnforcedNotice {
- margin: 8px 0 0;
- color: rgba(var(--center-channel-color-rgb), 0.72);
- font-size: 12px;
- line-height: 20px;
- }
}
diff --git a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx
index e18be24d03e2..8f7f35ff2337 100644
--- a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx
+++ b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx
@@ -16,6 +16,7 @@ describe('components/TeamSettings', () => {
const regenerateTeamInviteId = jest.fn().mockReturnValue({data: true});
const getTeamStats = jest.fn().mockResolvedValue({data: {total_member_count: 10, active_member_count: 10}});
const getTeamAccessControlPolicy = jest.fn().mockResolvedValue({data: {policy: null, enforced: false}});
+ const getAccessControlPolicy = jest.fn().mockResolvedValue({data: undefined});
const searchUsersForExpression = jest.fn().mockResolvedValue({data: {users: [], total: 0}});
const createAccessControlTeamSyncJob = jest.fn().mockResolvedValue({data: {}});
const baseActions = {
@@ -23,6 +24,7 @@ describe('components/TeamSettings', () => {
regenerateTeamInviteId,
getTeamStats,
getTeamAccessControlPolicy,
+ getAccessControlPolicy,
searchUsersForExpression,
createAccessControlTeamSyncJob,
};
@@ -203,4 +205,120 @@ describe('components/TeamSettings', () => {
await userEvent.click(screen.getByTestId('SaveChangesPanel__save-btn'));
expect(patchTeam).toHaveBeenCalledWith({id: 'team_id', allow_open_invite: false});
});
+
+ test('auto-add-ON governed public team: Private card opens mode-flip modal (not trapped)', async () => {
+ const props = {
+ ...defaultProps,
+ team: TestHelper.getTeamMock({
+ id: 'team_id',
+ type: 'O',
+ allow_open_invite: true,
+ policy_enforced: true,
+ policy_is_active: true,
+ }),
+ teamMembershipAccessControlEnabled: true,
+ };
+ renderWithContext();
+ await userEvent.click(screen.getByText('Private Team'));
+ expect(await screen.findByText('Switch to Private Team?')).toBeInTheDocument();
+ expect(await screen.findByText('Switch to Private')).toBeInTheDocument();
+ });
+
+ test('parent-policy governed team: mode-flip modal shows the resolved member count', async () => {
+ getTeamAccessControlPolicy.mockResolvedValueOnce({
+ data: {policy: {id: 'team_id', rules: [], imports: ['parent1']}, enforced: true},
+ });
+ getAccessControlPolicy.mockResolvedValueOnce({
+ data: {
+ id: 'parent1',
+ rules: [{actions: ['membership'], expression: 'user.attributes.Department == "Engineering"'}],
+ },
+ });
+ searchUsersForExpression.mockResolvedValueOnce({data: {users: [], total: 9}});
+ getTeamStats.mockResolvedValueOnce({data: {total_member_count: 10, active_member_count: 10}});
+
+ const props = {
+ ...defaultProps,
+ team: TestHelper.getTeamMock({id: 'team_id', type: 'O', allow_open_invite: true, policy_enforced: true}),
+ teamMembershipAccessControlEnabled: true,
+ };
+ renderWithContext();
+ await userEvent.click(screen.getByText('Private Team'));
+
+ expect(await screen.findByText(/1 current member does not meet criteria/i)).toBeInTheDocument();
+ expect(getAccessControlPolicy).toHaveBeenCalledWith('parent1');
+ expect(searchUsersForExpression).toHaveBeenCalledWith(
+ 'user.attributes.Department == "Engineering"', '', '', 1, undefined, 'team_id',
+ );
+ });
+
+ test('parent-policy fetch failure: modal falls back to the generic message', async () => {
+ getTeamAccessControlPolicy.mockResolvedValueOnce({
+ data: {policy: {id: 'team_id', rules: [], imports: ['parent1']}, enforced: true},
+ });
+ getAccessControlPolicy.mockResolvedValueOnce({error: {message: 'boom'}});
+
+ const props = {
+ ...defaultProps,
+ team: TestHelper.getTeamMock({id: 'team_id', type: 'O', allow_open_invite: true, policy_enforced: true}),
+ teamMembershipAccessControlEnabled: true,
+ };
+ renderWithContext();
+ await userEvent.click(screen.getByText('Private Team'));
+
+ expect(await screen.findByText(/Some members may not meet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/do not meet criteria/i)).not.toBeInTheDocument();
+ });
+
+ test('own-rules governed team: mode-flip modal counts from the inline expression', async () => {
+ getTeamAccessControlPolicy.mockResolvedValueOnce({
+ data: {
+ policy: {
+ id: 'team_id',
+ imports: [],
+ rules: [{actions: ['membership'], expression: 'user.attributes.Department == "Engineering"'}],
+ },
+ enforced: true,
+ },
+ });
+ searchUsersForExpression.mockResolvedValueOnce({data: {users: [], total: 8}});
+ getTeamStats.mockResolvedValueOnce({data: {total_member_count: 10, active_member_count: 10}});
+
+ const props = {
+ ...defaultProps,
+ team: TestHelper.getTeamMock({id: 'team_id', type: 'O', allow_open_invite: true, policy_enforced: true}),
+ teamMembershipAccessControlEnabled: true,
+ };
+ renderWithContext();
+ await userEvent.click(screen.getByText('Private Team'));
+
+ expect(await screen.findByText(/2 current members do not meet criteria/i)).toBeInTheDocument();
+ expect(getAccessControlPolicy).not.toHaveBeenCalled();
+ });
+
+ test('governed team with all members qualifying: modal shows a no-removals reassurance', async () => {
+ getTeamAccessControlPolicy.mockResolvedValueOnce({
+ data: {
+ policy: {
+ id: 'team_id',
+ imports: [],
+ rules: [{actions: ['membership'], expression: 'user.attributes.Department == "Engineering"'}],
+ },
+ enforced: true,
+ },
+ });
+ searchUsersForExpression.mockResolvedValueOnce({data: {users: [], total: 10}});
+ getTeamStats.mockResolvedValueOnce({data: {total_member_count: 10, active_member_count: 10}});
+
+ const props = {
+ ...defaultProps,
+ team: TestHelper.getTeamMock({id: 'team_id', type: 'O', allow_open_invite: true, policy_enforced: true}),
+ teamMembershipAccessControlEnabled: true,
+ };
+ renderWithContext();
+ await userEvent.click(screen.getByText('Private Team'));
+
+ expect(await screen.findByText(/no one will be removed/i)).toBeInTheDocument();
+ expect(screen.queryByText(/do not meet criteria/i)).not.toBeInTheDocument();
+ });
});
diff --git a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx
index a5237d8b71c3..5a111f6a704b 100644
--- a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx
+++ b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx
@@ -5,7 +5,7 @@ import React, {useCallback, useState, useRef} from 'react';
import {FormattedMessage} from 'react-intl';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
-import {getMembershipRule} from '@mattermost/types/access_control';
+import {combineMembershipExpressions, getMembershipRule} from '@mattermost/types/access_control';
import ConfirmModal from 'components/confirm_modal';
import SaveChangesPanel, {type SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
@@ -72,18 +72,29 @@ const AccessTab = ({showTabSwitchError, areThereUnsavedChanges, setShowTabSwitch
try {
const policyResult = await actions.getTeamAccessControlPolicy(team.id);
const policyData = policyResult?.data as {policy: AccessControlPolicy | null; enforced: boolean} | undefined;
- const expression = getMembershipRule(policyData?.policy?.rules)?.expression ?? '';
+ const teamExpression = getMembershipRule(policyData?.policy?.rules)?.expression;
+
+ // Parent-governed teams keep the rules in the imported policy, not here.
+ const parentIds = policyData?.policy?.imports ?? [];
+ const parentPolicies = await Promise.all(
+ parentIds.map((id) => actions.getAccessControlPolicy(id)),
+ );
+
+ // A dropped import would understate the count; fall back to the generic message.
+ if (parentPolicies.some((result) => result?.error || !result?.data)) {
+ return null;
+ }
+ const parentExpressions = parentPolicies.map((result) =>
+ getMembershipRule((result.data as AccessControlPolicy).rules)?.expression,
+ );
+
+ const expression = combineMembershipExpressions([teamExpression, ...parentExpressions]);
if (!expression) {
return null;
}
- // Count the members who would be removed as (active members) minus
- // (members matching the rules). Both counts come from the server:
- // searchUsersForExpression with the team id scopes the match count to
- // this team's members, and the stats endpoint gives the active total.
- // This avoids paging the member list, which the server caps at 200 per
- // request and would silently undercount on larger teams.
+ // active - matching, server-side to avoid paging the member list.
const [searchResult, statsResult] = await Promise.all([
actions.searchUsersForExpression(expression, '', '', 1, undefined, team.id),
actions.getTeamStats(team.id),
@@ -173,18 +184,30 @@ const AccessTab = ({showTabSwitchError, areThereUnsavedChanges, setShowTabSwitch
setAreThereUnsavedChanges(false);
}, [isSaving, handleAllowedDomainsSubmit, handlePrivacySubmit, setShowTabSwitchError, setAreThereUnsavedChanges]);
- const modeFlipMessage = modeFlipMemberCount === null ? (
-
- ) : (
-
- );
+ let modeFlipMessage;
+ if (modeFlipMemberCount === null) {
+ modeFlipMessage = (
+
+ );
+ } else if (modeFlipMemberCount === 0) {
+ modeFlipMessage = (
+
+ );
+ } else {
+ modeFlipMessage = (
+
+ );
+ }
return (
{!team.group_constrained && (
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index a6e2b0050025..b9f18f1c0ad3 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -7157,8 +7157,6 @@
"team_settings_modal.title": "Team Settings",
"team_settings.access_policies.add_policy": "Add policy",
"team_settings.access_policies.title": "Channel membership policies",
- "team_settings.discoverability.policy_enforced_notice": "This team's membership is managed by a policy. Open access settings do not apply while a policy is active.",
- "team_settings.discoverability.policy_enforced_tooltip": "Membership is managed by a policy",
"team_settings.discoverability.private_description": "Only invited members can join",
"team_settings.discoverability.private_title": "Private Team",
"team_settings.discoverability.public_description": "Anyone on the server can find and join",
@@ -7186,6 +7184,7 @@
"team_settings.mode_flip_confirm.cancel": "Cancel",
"team_settings.mode_flip_confirm.confirm": "Switch to Private",
"team_settings.mode_flip_confirm.message_generic": "Switching to Private will activate strict ABAC enforcement. Some members may not meet the current policy criteria and will be removed at the next sync.",
+ "team_settings.mode_flip_confirm.message_no_removals": "Switching to Private will activate strict ABAC enforcement. All current members meet the criteria, so no one will be removed at the next sync.",
"team_settings.mode_flip_confirm.message_with_count": "Switching to Private will activate strict ABAC enforcement. {count} current {count, plural, one {member does} other {members do}} not meet criteria and will be removed at the next sync.",
"team_settings.mode_flip_confirm.title": "Switch to Private Team?",
"team_settings.openInviteDescription.error": "There was an error generating the invite code, please try again",
diff --git a/webapp/platform/types/src/access_control.test.ts b/webapp/platform/types/src/access_control.test.ts
index 87e72d9a913c..137b6c29a154 100644
--- a/webapp/platform/types/src/access_control.test.ts
+++ b/webapp/platform/types/src/access_control.test.ts
@@ -1,9 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {getMembershipRule, buildRulesWithMembership} from './access_control';
+import {getMembershipRule, buildRulesWithMembership, combineMembershipExpressions} from './access_control';
import type {AccessControlPolicyRule} from './access_control';
+describe('combineMembershipExpressions', () => {
+ test('returns empty string when no expressions', () => {
+ expect(combineMembershipExpressions([])).toBe('');
+ expect(combineMembershipExpressions([undefined, '', ' '])).toBe('');
+ });
+
+ test('returns a single expression as-is (trimmed)', () => {
+ expect(combineMembershipExpressions([' a == 1 '])).toBe('a == 1');
+ expect(combineMembershipExpressions([undefined, 'a == 1', ''])).toBe('a == 1');
+ });
+
+ test('ANDs multiple expressions, each wrapped in parentheses', () => {
+ expect(combineMembershipExpressions(['a == 1', 'b == 2'])).toBe('(a == 1) && (b == 2)');
+ expect(combineMembershipExpressions(['a == 1', undefined, 'b == 2', 'c == 3'])).
+ toBe('(a == 1) && (b == 2) && (c == 3)');
+ });
+});
+
describe('getMembershipRule', () => {
test('returns the membership rule when present', () => {
const rules: AccessControlPolicyRule[] = [
diff --git a/webapp/platform/types/src/access_control.ts b/webapp/platform/types/src/access_control.ts
index c7abd915fbf0..5a255de77dde 100644
--- a/webapp/platform/types/src/access_control.ts
+++ b/webapp/platform/types/src/access_control.ts
@@ -96,6 +96,22 @@ export function getMembershipRule(rules?: AccessControlPolicyRule[]): AccessCont
return undefined;
}
+// ANDs CEL membership expressions; searchUsersForExpression doesn't resolve
+// policy imports, so callers combine them here.
+export function combineMembershipExpressions(expressions: Array): string {
+ const parts = expressions.
+ map((expr) => expr?.trim()).
+ filter((expr): expr is string => Boolean(expr));
+
+ if (parts.length === 0) {
+ return '';
+ }
+ if (parts.length === 1) {
+ return parts[0]!;
+ }
+ return parts.map((expr) => `(${expr})`).join(' && ');
+}
+
/**
* Returns the rules that govern non-membership actions (file upload/download
* permission rules in v0.4). Membership rules and wildcard-action rules are