Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/main/administration-guide/upgrade/upgrade-v11.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).
42 changes: 0 additions & 42 deletions docs/main/product-overview/whats-new-in-v11.mdx

This file was deleted.

2 changes: 0 additions & 2 deletions docs/site/scripts/gen-documentation-sidebar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

// ---------------------------------------------------------------------------
Expand Down
34 changes: 28 additions & 6 deletions docs/site/src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
createPublicTeam,
createPrivateTeam,
createTeamMembershipPolicy,
createParentMembershipPolicy,
assignTeamToParentPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from './helpers';
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
});
4 changes: 2 additions & 2 deletions server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)/...)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -802,28 +802,11 @@ export default class TeamDetails extends React.PureComponent<Props, State> {

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {Team} from '@mattermost/types/teams';

import {
createAccessControlTeamSyncJob,
getAccessControlPolicy,
getTeamAccessControlPolicy,
searchUsersForExpression,
} from 'mattermost-redux/actions/access_control';
Expand Down Expand Up @@ -42,6 +43,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
regenerateTeamInviteId,
getTeamStats,
getTeamAccessControlPolicy,
getAccessControlPolicy,
searchUsersForExpression,
createAccessControlTeamSyncJob,
}, dispatch),
Expand Down
Loading
Loading