[GH-29948] [GH-32467] Avoid fetching resources requiring a license if it isn't the case (#34206)

* [GH-30388] Don't load custom profile attributes if not licensed or disabled

* [GH-29948] Don't load scheduled posts if not enabled

Fixes #29948

* chore: add and use isCustomProfileAttributesEnabled selector

* tests: fix type check error

* review: fix lint, handle props update, add tests and cleanup uneeded code

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nicolas Le Cam
2026-04-15 10:53:59 -04:00
committed by GitHub
co-authored by Mattermost Build
parent 0fcf3b5ef2
commit 62d0ab633f
10 changed files with 404 additions and 13 deletions
@@ -23,6 +23,7 @@ import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general';
import {getBool, getIsOnboardingFlowEnabled, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships, getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {calculateUnreadCount} from 'mattermost-redux/utils/channel_utils';
@@ -411,7 +412,9 @@ export async function redirectUserToDefaultTeam(searchParams?: URLSearchParams)
if (team && team.delete_at === 0) {
const channel = await getTeamRedirectChannelIfIsAccesible(user, team);
if (channel) {
dispatch(fetchTeamScheduledPosts(team.id, true));
if (isScheduledPostsEnabled(state)) {
dispatch(fetchTeamScheduledPosts(team.id, true));
}
dispatch(selectChannel(channel.id));
historyPushWithQueryParams(`/${team.name}/channels/${channel.name}`, searchParams);
return;
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import cloneDeep from 'lodash/cloneDeep';
import {WebSocketEvents} from '@mattermost/client';
import {CloudTypes} from 'mattermost-redux/action_types';
@@ -705,8 +707,58 @@ describe('reconnect', () => {
});
test('should reload custom profile attribute fields on reconnect', () => {
const clonedMockState = cloneDeep(mockState);
mockState = mergeObjects(
mockState,
{
entities: {
general: {
license: {
SkuShortName: 'enterprise',
},
config: {
FeatureFlagCustomProfileAttributes: 'true',
},
},
},
},
);
reconnect();
expect(getCustomProfileAttributeFields).toHaveBeenCalled();
// Restore mock state
mockState = clonedMockState;
});
test.each([
{SkuShortName: 'starter', FeatureFlagCustomProfileAttributes: 'true'},
{SkuShortName: 'enterprise', FeatureFlagCustomProfileAttributes: 'false'},
])("should not reload custom profile attribute fields on reconnect if feature isn't available", ({SkuShortName, FeatureFlagCustomProfileAttributes}) => {
const clonedMockState = cloneDeep(mockState);
mockState = mergeObjects(
mockState,
{
entities: {
general: {
license: {
SkuShortName,
},
config: {
FeatureFlagCustomProfileAttributes,
},
},
},
},
);
reconnect();
expect(getCustomProfileAttributeFields).not.toHaveBeenCalled();
// Restore mock state
mockState = clonedMockState;
});
});
@@ -107,11 +107,12 @@ import {
hasAutotranslationBecomeEnabled,
} from 'mattermost-redux/selectors/entities/channels';
import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getConfig, getLicense, isCustomProfileAttributesEnabled} from 'mattermost-redux/selectors/entities/general';
import {getGroup} from 'mattermost-redux/selectors/entities/groups';
import {getPost, getMostRecentPostIdInChannel, getTeamIdFromPost} from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveISystemPermission, haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {
getTeamIdByChannelId,
getMyTeams,
@@ -157,6 +158,7 @@ import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins';
import {getHistory} from 'utils/browser_history';
import {ActionTypes, Constants, AnnouncementBarMessages, SocketEvents, UserStatuses, ModalIdentifiers, PageLoadContext} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import {isEnterpriseLicense} from 'utils/license_utils';
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import {getSiteURL} from 'utils/url';
@@ -274,7 +276,9 @@ export function reconnect() {
}
dispatch(fetchAllMyTeamsChannels());
dispatch(fetchTeamScheduledPosts(currentTeamId, true, true));
if (isScheduledPostsEnabled(state)) {
dispatch(fetchTeamScheduledPosts(currentTeamId, true, true));
}
dispatch(fetchAllMyChannelMembers());
dispatch(fetchMyCategories(currentTeamId));
loadProfilesForSidebar();
@@ -320,7 +324,9 @@ export function reconnect() {
});
// Refresh custom profile attributes on reconnect
dispatch(getCustomProfileAttributeFields());
if (isEnterpriseLicense(getLicense(state)) && isCustomProfileAttributesEnabled(state)) {
dispatch(getCustomProfileAttributeFields());
}
if (state.websocket.lastDisconnectAt) {
dispatch(checkForModifiedUsers());
@@ -494,6 +494,253 @@ exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = `
</div>
`;
exports[`SystemUserDetail should not fetch CPA data if disabled 1`] = `
<div>
<div
class="SystemUserDetail wrapper--fixed"
>
<div
class="admin-console__header with-back"
>
<div>
<a
class="fa fa-angle-left back"
href="/admin_console/user_management/users"
/>
User Configuration
</div>
</div>
<div
class="admin-console__wrapper"
>
<div
class="admin-console__content"
>
<div
class="AdminUserCard"
>
<div
class="AdminUserCard__header"
>
<button
aria-expanded="false"
aria-haspopup="dialog"
class="status-wrapper style--none admin-user-card"
style="border-radius: 50%; width: 128px; height: 128px;"
>
<span
class="profile-icon "
>
<img
alt="user profile image"
class="Avatar Avatar-xxl"
loading="lazy"
src="/api/v4/users/user_id/image"
/>
</span>
</button>
<div
class="AdminUserCard__user-info"
>
<span>
</span>
<span
class="AdminUserCard__user-nickname"
/>
</div>
<div
class="AdminUserCard__user-id"
>
User ID: user_id
</div>
</div>
<div
class="AdminUserCard__body"
>
<span />
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<input
aria-describedby="username-error"
aria-invalid="false"
class="form-control"
placeholder="Enter username"
type="text"
value="some-user"
/>
</label>
</div>
<div
class="field-column right"
>
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
aria-describedby="email-error"
aria-invalid="false"
class="form-control"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
>
<button
class="btn btn-secondary"
>
Reset Password
</button>
<button
class="btn btn-secondary btn-danger"
>
Deactivate
</button>
</div>
</div>
<div
class="AdminPanel clearfix "
>
<div
class="header"
>
<div>
<h3>
Team Membership
</h3>
<div
class="mt-2"
>
Teams to which this user belongs
</div>
</div>
<div
class="button"
>
<div
class="add-team-button"
>
<button
class="btn btn-primary"
type="button"
>
Add Team
</button>
</div>
</div>
</div>
<div />
<div
class="AbstractList"
>
<div
class="AbstractList__body"
>
<div
class="AbstractList__empty"
>
No teams found
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="admin-console-save"
>
<div
class="admin-console-save-buttons"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
aria-live="polite"
class="error-message"
role="alert"
/>
</div>
</div>
</div>
`;
exports[`SystemUserDetail should not show manage user settings button when user doesn't have permission 1`] = `
<div>
<div
@@ -10,18 +10,22 @@ import {getCustomProfileAttributeFields} from 'mattermost-redux/actions/general'
import {getUserPreferences} from 'mattermost-redux/actions/preferences';
import {addUserToTeam} from 'mattermost-redux/actions/teams';
import {updateUserActive, updateUserAuth, getUser, patchUser, updateUserMfa, getCustomProfileAttributeValues, saveCustomProfileAttribute} from 'mattermost-redux/actions/users';
import {getConfig, getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general';
import {getConfig, getCustomProfileAttributes, getLicense, isCustomProfileAttributesEnabled} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {setNavigationBlocked} from 'actions/admin_actions.jsx';
import {openModal} from 'actions/views/modals';
import {getShowLockedManageUserSettings, getShowManageUserSettings} from 'selectors/admin_console';
import {isEnterpriseLicense} from 'utils/license_utils';
import SystemUserDetail from './system_user_detail';
function mapStateToProps(state: GlobalState) {
const license = getLicense(state);
const config = getConfig(state);
const customProfileAttributeFields = Object.values(getCustomProfileAttributes(state));
const customProfileAttributeEnabled = isEnterpriseLicense(license) && isCustomProfileAttributesEnabled(state);
const customProfileAttributeFields = customProfileAttributeEnabled ? getCustomProfileAttributes(state) : [];
const showManageUserSettings = getShowManageUserSettings(state);
const showLockedManageUserSettings = getShowLockedManageUserSettings(state);
@@ -29,6 +33,7 @@ function mapStateToProps(state: GlobalState) {
return {
currentUserId: getCurrentUserId(state),
mfaEnabled: config?.EnableMultifactorAuthentication === 'true' || false,
customProfileAttributeEnabled,
customProfileAttributeFields,
showManageUserSettings,
showLockedManageUserSettings,
@@ -32,6 +32,7 @@ describe('SystemUserDetail', () => {
showManageUserSettings: false,
showLockedManageUserSettings: false,
mfaEnabled: false,
customProfileAttributeEnabled: true,
customProfileAttributeFields: [],
patchUser: jest.fn(),
updateUserAuth: jest.fn(),
@@ -135,6 +136,26 @@ describe('SystemUserDetail', () => {
expect(container).toMatchSnapshot();
});
test('should not fetch CPA data if disabled', async () => {
const getCustomProfileAttributeFields = jest.fn().mockResolvedValue({data: []});
const getCustomProfileAttributeValues = jest.fn().mockResolvedValue({data: {}});
const props = {
...defaultProps,
customProfileAttributeEnabled: false,
getCustomProfileAttributeFields,
getCustomProfileAttributeValues,
};
const {container} = renderWithContext(<SystemUserDetail {...props}/>);
await waitForLoadingToFinish();
expect(getCustomProfileAttributeFields).not.toHaveBeenCalled();
expect(getCustomProfileAttributeValues).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
describe('change detection', () => {
test('should detect email changes and enable save', async () => {
const userEventInstance = userEvent.setup();
@@ -180,13 +180,12 @@ export class SystemUserDetail extends PureComponent<Props, State> {
try {
// Fetch user data and CPA values in parallel
const [userResult, cpaResult] = await Promise.all([
const [userResult, cpaValues] = await Promise.all([
this.props.getUser(userId) as ActionResult<UserProfile, ServerError>,
this.props.getCustomProfileAttributeValues(userId),
this.props.customProfileAttributeEnabled ? this.getCustomProfileAttributeValues(userId) : {},
]);
if (userResult.data) {
const cpaValues = (cpaResult as {data?: Record<string, string | string[]>}).data || {};
this.setState({
user: userResult.data,
emailField: userResult.data.email, // Set emailField to the email of the user for editing purposes
@@ -211,6 +210,11 @@ export class SystemUserDetail extends PureComponent<Props, State> {
}
};
getCustomProfileAttributeValues = async (userId: UserProfile['id']) => {
return this.props.getCustomProfileAttributeValues(userId).
then((result: { data?: Record<string, string | string[]> }) => result.data || {});
};
componentDidMount() {
const userId = this.props.match.params.user_id ?? '';
if (userId) {
@@ -219,7 +223,7 @@ export class SystemUserDetail extends PureComponent<Props, State> {
}
// Fetch CPA field definitions if not already available
if (this.props.customProfileAttributeFields.length === 0) {
if (this.props.customProfileAttributeEnabled && this.props.customProfileAttributeFields.length === 0) {
this.props.getCustomProfileAttributeFields();
}
}
@@ -232,6 +236,24 @@ export class SystemUserDetail extends PureComponent<Props, State> {
if (hasChanges !== hadChanges) {
this.props.setNavigationBlocked(hasChanges);
}
// Fetch CPA field definitions if CPA has been enabled
const hasCpaBeenEnabled = !prevProps.customProfileAttributeEnabled && this.props.customProfileAttributeEnabled;
if (hasCpaBeenEnabled) {
if (this.state.user) {
this.getCustomProfileAttributeValues(this.state.user.id).
then((cpaValues) => {
this.setState({
customProfileAttributeValues: cpaValues,
originalCpaValues: {...cpaValues}, // Deep copy for change tracking
});
});
}
if (this.props.customProfileAttributeFields.length === 0) {
this.props.getCustomProfileAttributeFields();
}
}
}
private hasUnsavedChanges = (state: State = this.state): boolean => {
@@ -248,7 +270,12 @@ export class SystemUserDetail extends PureComponent<Props, State> {
};
private hasCpaChanges = (state: State = this.state): boolean => {
const {customProfileAttributeFields} = this.props;
const {customProfileAttributeEnabled, customProfileAttributeFields} = this.props;
if (!customProfileAttributeEnabled) {
return false;
}
for (const field of customProfileAttributeFields) {
const currentValue = state.customProfileAttributeValues[field.id];
const originalValue = state.originalCpaValues[field.id];
@@ -4,6 +4,7 @@
import React from 'react';
import {MemoryRouter, Route} from 'react-router-dom';
import type {GeneralState} from '@mattermost/types/general';
import type {DeepPartial} from '@mattermost/types/utilities';
import {renderWithContext, screen, waitFor} from 'tests/react_testing_utils';
@@ -201,6 +202,27 @@ describe('components/drafts/drafts_link', () => {
});
});
it.each<DeepPartial<GeneralState>>([
{config: {ScheduledPosts: 'false'}},
{license: {IsLicensed: 'false'}},
])('should not fetch scheduled posts when component mounts if disabled', async (partialConf) => {
const fetchTeamScheduledPosts = require('mattermost-redux/actions/scheduled_posts').fetchTeamScheduledPosts;
const state: DeepPartial<GlobalState> = {
...baseState,
entities: {
...baseState.entities,
general: {
...baseState.entities?.general,
...partialConf,
},
},
};
renderWithRouter(state);
expect(fetchTeamScheduledPosts).not.toHaveBeenCalled();
});
it('should be active when on drafts route', () => {
const state: DeepPartial<GlobalState> = {
...baseState,
@@ -16,6 +16,7 @@ import {selectTeam} from 'mattermost-redux/actions/teams';
import {getThread} from 'mattermost-redux/actions/threads';
import {getProfilesByIds} from 'mattermost-redux/actions/users';
import {getChannel, getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
@@ -72,6 +73,7 @@ export default function ThreadPopout() {
}
return getThreadOrSynthetic(state, post);
});
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
usePopoutTitle(getThreadPopoutTitle(channel));
@@ -92,10 +94,12 @@ export default function ThreadPopout() {
useEffect(() => {
if (teamId) {
dispatch(fetchChannelsAndMembers(teamId));
dispatch(fetchTeamScheduledPosts(teamId, true));
if (isScheduledPostEnabled) {
dispatch(fetchTeamScheduledPosts(teamId, true));
}
dispatch(selectTeam(teamId));
}
}, [dispatch, teamId]);
}, [dispatch, teamId, isScheduledPostEnabled]);
useEffect(() => {
if (teamId) {
@@ -22,6 +22,10 @@ export function getFeatureFlagValue(state: GlobalState, key: keyof FeatureFlags)
return getConfig(state)?.[`FeatureFlag${key}` as keyof Partial<ClientConfig>];
}
export function isCustomProfileAttributesEnabled(state: GlobalState): boolean {
return getConfig(state).FeatureFlagCustomProfileAttributes === 'true';
}
export type PasswordConfig = {
minimumLength: number;
requireLowercase: boolean;