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