Merge master into debug/binary-timeout

This commit is contained in:
Pavel Zeman 2026-04-10 10:01:55 +00:00 committed by GitHub
commit d552098d62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1109 additions and 471 deletions

51
.github/codecov.yml vendored
View file

@ -1,17 +1,42 @@
comment:
layout: "condensed_header, condensed_files, condensed_footer"
behavior: default
require_changes: "uncovered_patch" # only post comment if the patch has uncovered lines
hide_project_coverage: true # only show coverage on the git diff
codecov:
require_ci_to_pass: false
# Wait for all coverage uploads (4 server shards + 1 webapp) before
# computing status. Without this, Codecov may report partial coverage
# from the first shard to finish, showing a misleading drop on the PR.
notify:
after_n_builds: 5
coverage:
status:
changes: false
patch: false
project:
default:
threshold: 1.0
codecov:
notify:
after_n_builds: 2 # Server and webapp at this point
ignore:
- ^store/storetest.*
target: auto
threshold: 1%
informational: true
patch:
default:
target: 50%
informational: true
# Exclude generated code, mocks, and test infrastructure from reporting.
# Go compiles these into the test binary, so they appear in cover.out,
# but they aren't production code and inflate the denominator.
ignore:
- "server/**/retrylayer/**"
- "server/**/timerlayer/**"
- "server/**/*_serial_gen.go"
- "server/**/mocks/**"
- "server/**/storetest/**"
- "server/**/plugintest/**"
- "server/**/searchtest/**"
flags:
server:
after_n_builds: 4 # 4 server test shards
webapp:
after_n_builds: 1 # 1 merged webapp upload
comment:
layout: "condensed_header,diff,flags"
behavior: default
require_changes: true

View file

@ -208,6 +208,7 @@ jobs:
logsartifact: postgres-binary-server-test-logs
go-version: ${{ needs.go.outputs.version }}
fips-enabled: false
fullyparallel: false
# -- Sharded into 4 parallel runners for ~88% wall-time improvement --
test-postgres-normal:
name: Postgres (shard ${{ matrix.shard }})
@ -270,22 +271,27 @@ jobs:
artifact-name: postgres-server-fips-test-logs
test-coverage:
name: Generate Test Coverage
# Disabled: Running out of memory and causing spurious failures.
# Old condition: ${{ github.event_name != 'pull_request' || !startsWith(github.event.pull_request.base.ref, 'release-') }}
if: false
name: "Coverage (shard ${{ matrix.shard }})"
if: ${{ github.event_name != 'pull_request' || !startsWith(github.event.pull_request.base.ref, 'release-') }}
needs: go
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
uses: ./.github/workflows/server-test-template.yml
secrets: inherit
with:
name: Generate Test Coverage
name: "Coverage (shard ${{ matrix.shard }})"
datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10
drivername: postgres
logsartifact: coverage-server-test-logs
logsartifact: "coverage-server-test-logs-shard-${{ matrix.shard }}"
fullyparallel: true
allow-failure: true
enablecoverage: true
go-version: ${{ needs.go.outputs.version }}
fips-enabled: false
shard-index: ${{ matrix.shard }}
shard-total: 4
test-mmctl:
name: Run mmctl tests
needs: go

View file

@ -222,6 +222,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
disable_search: true
files: server/cover.out
flags: server
- name: Stop docker compose
run: |

View file

@ -223,6 +223,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
disable_search: true
files: ./webapp/channels/coverage/merged/lcov.info
flags: webapp
build:
needs: check-lint

View file

@ -209,12 +209,8 @@ describe('Authentication', () => {
// # Go to front page
cy.visit('/login');
// * Assert that create account button is visible
cy.findByText('Don\'t have an account?', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').click();
// * Verify redirection to access problem page since account creation is disabled
cy.url().should('include', '/access_problem');
cy.findByText('Contact your workspace admin');
// * Assert that create account button is not visible
cy.findByText('Don\'t have an account?', {timeout: TIMEOUTS.ONE_MIN}).should('not.exist');
// # Go to sign up with email page
cy.visit('/signup_user_complete');

View file

@ -36,8 +36,7 @@ describe('Login page with close server', () => {
// Restore backed up settings
cy.apiAdminLogin().apiUpdateConfig(oldSettings);
});
it('MM-47222 Should verify access problem page can be reached', () => {
cy.findByText('Don\'t have an account?').should('be.visible').click();
cy.findByText('Contact your workspace admin').should('be.visible');
it('MM-47222 Should verify signup link not visible', () => {
cy.findByText('Don\'t have an account?').should('not.exist');
});
});

View file

@ -49,10 +49,28 @@ describe('Customization', () => {
// # Save setting
saveSetting();
// # Verify that after page reload image exist
cy.reload();
cy.findByTestId('CustomBrandImage').should('be.visible').within(() => {
// * Verify that after page reload image exist
cy.get('img').should('have.attr', 'src').and('include', '/api/v4/brand/image?t=');
// * Verify that there's an option to delete the image.
cy.findByTestId('remove-image__btn').should('be.visible');
// # delete the image
cy.findByTestId('remove-image__btn').click();
});
// # Save setting
saveSetting();
cy.reload();
cy.findByTestId('CustomBrandImage').should('be.visible').within(() => {
// * Verify that after page reload, the image doesn't exist.
cy.findByAltText('brand image').should('not.exist');
// * Verify there's no option to delete the image.
cy.findByTestId('remove-image__btn').should('not.exist');
});
});

View file

@ -165,7 +165,7 @@ PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.0
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.12.0
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.6.0
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.1

View file

@ -657,6 +657,10 @@ func getTeamMember(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
team.SanitizeRoleData(c.AppContext.Session().UserId)
}
if err := json.NewEncoder(w).Encode(team); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
@ -695,6 +699,13 @@ func getTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
currentUserId := c.AppContext.Session().UserId
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
for _, m := range members {
m.SanitizeRoleData(currentUserId)
}
}
js, err := json.Marshal(members)
if err != nil {
c.Err = model.NewAppError("getTeamMembers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
@ -734,6 +745,13 @@ func getTeamMembersForUser(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
currentUserId := c.AppContext.Session().UserId
for _, m := range members {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), m.TeamId, model.PermissionManageTeamRoles) {
m.SanitizeRoleData(currentUserId)
}
}
js, err := json.Marshal(members)
if err != nil {
c.Err = model.NewAppError("getTeamMembersForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
@ -777,6 +795,13 @@ func getTeamMembersByIds(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
currentUserId := c.AppContext.Session().UserId
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
for _, m := range members {
m.SanitizeRoleData(currentUserId)
}
}
js, err := json.Marshal(members)
if err != nil {
c.Err = model.NewAppError("getTeamMembersByIds", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
@ -883,6 +908,10 @@ func addTeamMember(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec.AddEventObjectType("team_member") // TODO verify this is the final state. should it be the team instead?
auditRec.Success()
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
tm.SanitizeRoleData(c.AppContext.Session().UserId)
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(tm); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
@ -1037,6 +1066,15 @@ func addTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
currentUserId := c.AppContext.Session().UserId
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
for _, m := range membersWithErrors {
if m.Member != nil {
m.Member.SanitizeRoleData(currentUserId)
}
}
}
var (
js []byte
err error

View file

@ -4609,3 +4609,247 @@ func TestInvalidateAllEmailInvites(t *testing.T) {
CheckOKStatus(t, res)
})
}
func setupTeamWithAdminAndMember(t *testing.T, th *TestHelper) *model.Client4 {
t.Helper()
th.UpdateUserToTeamAdmin(t, th.BasicUser2, th.BasicTeam)
require.Nil(t, th.App.Srv().InvalidateAllCaches())
teamAdminClient := th.CreateClient()
_, _, err := teamAdminClient.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
return teamAdminClient
}
func assertRoleDataSanitized(t *testing.T, m *model.TeamMember) {
t.Helper()
assert.Empty(t, m.Roles)
assert.Empty(t, m.ExplicitRoles)
assert.False(t, m.SchemeAdmin)
assert.False(t, m.SchemeGuest)
assert.False(t, m.SchemeUser)
assert.Equal(t, int64(-1), m.DeleteAt)
}
func TestGetTeamMembersRoleDataSanitization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
teamAdminClient := setupTeamWithAdminAndMember(t, th)
t.Run("non-admin cannot see role data of others", func(t *testing.T) {
members, _, err := th.Client.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
require.NoError(t, err)
for _, m := range members {
if m.UserId != th.BasicUser.Id {
assertRoleDataSanitized(t, m)
}
}
})
t.Run("non-admin sees own role data", func(t *testing.T) {
members, _, err := th.Client.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
require.NoError(t, err)
for _, m := range members {
if m.UserId == th.BasicUser.Id {
assert.True(t, m.SchemeUser)
return
}
}
require.Fail(t, "current user not found in members")
})
t.Run("team admin sees full role data for other user", func(t *testing.T) {
members, _, err := teamAdminClient.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
require.NoError(t, err)
for _, m := range members {
if m.UserId == th.BasicUser.Id {
assert.True(t, m.SchemeUser)
return
}
}
require.Fail(t, "target user not found in members")
})
t.Run("system admin sees full role data", func(t *testing.T) {
members, _, err := th.SystemAdminClient.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
require.NoError(t, err)
for _, m := range members {
if m.UserId == th.BasicUser2.Id {
assert.True(t, m.SchemeAdmin)
return
}
}
require.Fail(t, "team admin not found in members")
})
}
func TestGetTeamMemberRoleDataSanitization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
teamAdminClient := setupTeamWithAdminAndMember(t, th)
t.Run("non-admin cannot see role data of others", func(t *testing.T) {
member, _, err := th.Client.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id, "")
require.NoError(t, err)
assertRoleDataSanitized(t, member)
})
t.Run("non-admin sees own role data", func(t *testing.T) {
member, _, err := th.Client.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "")
require.NoError(t, err)
assert.True(t, member.SchemeUser)
})
t.Run("team admin sees full role data for other user", func(t *testing.T) {
member, _, err := teamAdminClient.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "")
require.NoError(t, err)
assert.True(t, member.SchemeUser)
})
t.Run("system admin sees full role data", func(t *testing.T) {
member, _, err := th.SystemAdminClient.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id, "")
require.NoError(t, err)
assert.True(t, member.SchemeAdmin)
})
}
func TestGetTeamMembersByIdsRoleDataSanitization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
teamAdminClient := setupTeamWithAdminAndMember(t, th)
t.Run("non-admin cannot see role data of others", func(t *testing.T) {
members, _, err := th.Client.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser2.Id})
require.NoError(t, err)
require.Len(t, members, 1)
assertRoleDataSanitized(t, members[0])
})
t.Run("non-admin sees own role data", func(t *testing.T) {
members, _, err := th.Client.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser.Id})
require.NoError(t, err)
require.Len(t, members, 1)
assert.True(t, members[0].SchemeUser)
})
t.Run("team admin sees full role data for other user", func(t *testing.T) {
members, _, err := teamAdminClient.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser.Id})
require.NoError(t, err)
require.Len(t, members, 1)
assert.True(t, members[0].SchemeUser)
})
t.Run("system admin sees full role data", func(t *testing.T) {
members, _, err := th.SystemAdminClient.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser2.Id})
require.NoError(t, err)
require.Len(t, members, 1)
assert.True(t, members[0].SchemeAdmin)
})
}
func TestAddTeamMemberRoleDataSanitization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
teamAdminClient := setupTeamWithAdminAndMember(t, th)
t.Run("team admin adding user sees full role data in response", func(t *testing.T) {
newUser := th.CreateUser(t)
tm, _, err := teamAdminClient.AddTeamMember(context.Background(), th.BasicTeam.Id, newUser.Id)
require.NoError(t, err)
assert.True(t, tm.SchemeUser)
})
t.Run("non-admin adding user sees sanitized role data in response", func(t *testing.T) {
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
th.AddPermissionToRole(t, model.PermissionAddUserToTeam.Id, model.TeamUserRoleId)
newUser := th.CreateUser(t)
tm, _, err := th.Client.AddTeamMember(context.Background(), th.BasicTeam.Id, newUser.Id)
require.NoError(t, err)
assertRoleDataSanitized(t, tm)
})
}
func TestAddTeamMembersRoleDataSanitization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
teamAdminClient := setupTeamWithAdminAndMember(t, th)
t.Run("team admin adding users sees full role data in response", func(t *testing.T) {
newUser := th.CreateUser(t)
members, _, err := teamAdminClient.AddTeamMembers(context.Background(), th.BasicTeam.Id, []string{newUser.Id})
require.NoError(t, err)
require.Len(t, members, 1)
assert.True(t, members[0].SchemeUser)
})
t.Run("non-admin adding users sees sanitized role data in response", func(t *testing.T) {
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
th.AddPermissionToRole(t, model.PermissionAddUserToTeam.Id, model.TeamUserRoleId)
newUser := th.CreateUser(t)
members, _, err := th.Client.AddTeamMembers(context.Background(), th.BasicTeam.Id, []string{newUser.Id})
require.NoError(t, err)
require.Len(t, members, 1)
assertRoleDataSanitized(t, members[0])
})
}
func TestGetTeamMembersForUserRoleDataSanitization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
teamAdminClient := setupTeamWithAdminAndMember(t, th)
t.Run("user sees own role data", func(t *testing.T) {
members, _, err := th.Client.GetTeamMembersForUser(context.Background(), th.BasicUser.Id, "")
require.NoError(t, err)
require.NotEmpty(t, members)
for _, m := range members {
assert.True(t, m.SchemeUser)
}
})
t.Run("non-admin cannot see role data of another user", func(t *testing.T) {
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
th.AddPermissionToRole(t, model.PermissionReadOtherUsersTeams.Id, model.SystemUserRoleId)
members, _, err := th.Client.GetTeamMembersForUser(context.Background(), th.BasicUser2.Id, "")
require.NoError(t, err)
require.NotEmpty(t, members)
for _, m := range members {
assertRoleDataSanitized(t, m)
}
})
t.Run("team admin sees full role data for other user in managed team", func(t *testing.T) {
members, _, err := teamAdminClient.GetTeamMembersForUser(context.Background(), th.BasicUser.Id, "")
require.NoError(t, err)
require.NotEmpty(t, members)
for _, m := range members {
if m.TeamId == th.BasicTeam.Id {
assert.True(t, m.SchemeUser)
return
}
}
require.Fail(t, "basic team membership not found")
})
t.Run("system admin sees full role data", func(t *testing.T) {
members, _, err := th.SystemAdminClient.GetTeamMembersForUser(context.Background(), th.BasicUser2.Id, "")
require.NoError(t, err)
require.NotEmpty(t, members)
for _, m := range members {
if m.TeamId == th.BasicTeam.Id {
assert.True(t, m.SchemeAdmin)
return
}
}
require.Fail(t, "basic team membership not found")
})
}

View file

@ -142,3 +142,14 @@ func (o *TeamMember) PreUpdate() {
func (o *TeamMember) GetRoles() []string {
return strings.Fields(o.Roles)
}
func (o *TeamMember) SanitizeRoleData(currentUserId string) {
if o.UserId != currentUserId {
o.Roles = ""
o.ExplicitRoles = ""
o.SchemeAdmin = false
o.SchemeGuest = false
o.SchemeUser = false
o.DeleteAt = -1
}
}

View file

@ -108,6 +108,20 @@ fi
cat gotestsum-*.json > gotestsum.json 2>/dev/null || true
# ── Merge coverage profiles within this shard (if coverage is enabled) ──
# A single shard may run multiple gotestsum invocations (light packages +
# heavy package splits), each producing its own cover-N.out. This merges
# them into one cover.out per shard. The cross-shard merge (combining all
# shards into a single report) is handled by Codecov's after_n_builds.
if [[ "${ENABLE_COVERAGE:-false}" == "true" ]] && ls cover-*.out 1>/dev/null 2>&1; then
echo "Merging coverage profiles..."
{
head -1 cover-0.out # "mode: atomic" header
tail -q -n +2 cover-*.out # data lines from all files
} > cover.out
echo "Merged $(ls cover-*.out | wc -l) coverage files into cover.out"
fi
if [[ $FAILURES -gt 0 ]]; then
echo "Shard complete: $RUN_IDX gotestsum runs, $FAILURES failed"
exit 1

View file

@ -1,40 +0,0 @@
.header-footer-route .header-footer-route-container {
display: flex;
justify-content: space-between;
}
.AccessProblem {
&__body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
&__title {
display: flex;
align-items: center;
margin: 32px 0 16px 0;
color: var(--portal-denim);
font-family: 'Metropolis';
font-size: 22px;
font-style: normal;
font-weight: 600;
line-height: 28px;
text-align: center;
}
&__description {
display: flex;
max-width: 640px;
align-items: center;
padding: 0 40px;
color: var(--portal-denim);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
text-align: center;
}
}

View file

@ -1,51 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {useHistory} from 'react-router-dom';
import AccessProblemSVG from 'components/common/svg_images_components/access_problem_svg';
import type {CustomizeHeaderType} from 'components/header_footer_route/header_footer_route';
import './access_problem.scss';
type AccessProblemProps = {
onCustomizeHeader?: CustomizeHeaderType;
}
const AccessProblem = ({
onCustomizeHeader,
}: AccessProblemProps) => {
const {formatMessage} = useIntl();
const history = useHistory();
const handleHeaderBackButtonOnClick = useCallback(() => {
history.goBack();
}, [history]);
useEffect(() => {
if (onCustomizeHeader) {
onCustomizeHeader({
onBackButtonClick: handleHeaderBackButtonOnClick,
});
}
}, [onCustomizeHeader, handleHeaderBackButtonOnClick]);
return (
<div className='AccessProblem__body'>
<AccessProblemSVG
width={320}
height={190}
/>
<div className='AccessProblem__title'>
{formatMessage({id: 'login.contact_admin.title', defaultMessage: 'Contact your workspace admin'})}
</div>
<div className='AccessProblem__description'>
{formatMessage({id: 'login.contact_admin.detail', defaultMessage: "To access your team's workspace, contact your workspace admin. If you've been invited already, check your email inbox for a Mattermost workspace invite."})}
</div>
</div>
);
};
export default AccessProblem;

View file

@ -1,30 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import React from 'react';
import {uploadBrandImage, deleteBrandImage} from 'actions/admin_actions.jsx';
import {Client4} from 'mattermost-redux/client';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import BrandImageSetting from './brand_image_setting';
// Real implementations are async (await dispatch(...)); mocks must return Promises so handleSave can await them.
jest.mock('actions/admin_actions.jsx', () => ({
...jest.requireActual('actions/admin_actions.jsx'),
uploadBrandImage: jest.fn(async () => {}),
deleteBrandImage: jest.fn(async () => {}),
}));
Client4.setUrl('http://localhost:8065');
describe('components/admin_console/brand_image_setting', () => {
beforeEach(() => {
jest.spyOn(global, 'fetch').mockResolvedValue({status: 404} as Response);
});
afterEach(() => {
jest.restoreAllMocks();
});
const baseProps = {
disabled: false,
setSaveNeeded: jest.fn(),
@ -32,71 +20,59 @@ describe('components/admin_console/brand_image_setting', () => {
unRegisterSaveAction: jest.fn(),
};
test('should have called deleteBrandImage or uploadBrandImage on save depending on component state', async () => {
let saveAction: (() => Promise<unknown>) | undefined;
const registerSaveAction = jest.fn((fn: () => Promise<unknown>) => {
saveAction = fn;
});
const deleteButtonTestId = 'remove-image__btn';
const {container, unmount} = renderWithContext(
<BrandImageSetting
{...baseProps}
registerSaveAction={registerSaveAction}
/>,
);
let scope: nock.Scope;
// Wait for componentDidMount fetch to resolve
await waitFor(() => {
expect(registerSaveAction).toHaveBeenCalled();
});
expect(saveAction).toBeDefined();
beforeAll(() => {
scope = nock(Client4.getBaseRoute()).persist().get('/brand/image').query(true).reply(200);
});
// Simulate selecting a file via the file input to set brandImage
const file = new File(['brand_image_file'], 'brand.png', {type: 'image/png'});
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).toBeInTheDocument();
await userEvent.upload(fileInput as HTMLInputElement, file);
afterAll(() => {
nock.cleanAll();
});
// Now call save - should call uploadBrandImage
await saveAction!();
expect(deleteBrandImage).toHaveBeenCalledTimes(0);
expect(uploadBrandImage).toHaveBeenCalledTimes(1);
test('should register and unregister save handler when mounted and unmounted respectively', () => {
const {unmount} = renderWithContext(<BrandImageSetting {...baseProps}/>);
expect(baseProps.registerSaveAction).toHaveBeenCalledTimes(1);
// To test deleteBrandImage path, unmount then re-mount with fetch returning 200
unmount();
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValueOnce({status: 200} as Response);
let saveAction2: (() => Promise<unknown>) | undefined;
const registerSaveAction2 = jest.fn((fn: () => Promise<unknown>) => {
saveAction2 = fn;
});
expect(baseProps.unRegisterSaveAction).toHaveBeenCalledTimes(1);
});
renderWithContext(
<BrandImageSetting
{...baseProps}
registerSaveAction={registerSaveAction2}
/>,
);
test('should show delete button if brand image exists', async () => {
renderWithContext(<BrandImageSetting {...baseProps}/>);
await waitFor(() => {
expect(registerSaveAction2).toHaveBeenCalled();
});
expect(saveAction2).toBeDefined();
await waitFor(() => expect(scope.isDone()).toBe(true));
expect(screen.getByTestId(deleteButtonTestId)).toBeVisible();
});
test('should hide delete button if the setting is disabled', async () => {
const props = {...baseProps, disabled: true};
renderWithContext(<BrandImageSetting {...props}/>);
await waitFor(() => expect(screen.queryByTestId(deleteButtonTestId)).toBe(null));
});
test('should call setSaveNeeded when a brand image is uploaded', async () => {
renderWithContext(<BrandImageSetting {...baseProps}/>);
await userEvent.upload(screen.getByTestId('file__upload-input'), new File(['brand_image_file'], 'brand_image_file.png', {type: 'image/png'}));
expect(baseProps.setSaveNeeded).toHaveBeenCalledTimes(1);
});
test('should call setSaveNeeded when the delete button is pressed', async () => {
renderWithContext(<BrandImageSetting {...baseProps}/>);
const deleteButton = await screen.findByTestId(deleteButtonTestId);
// Wait for the brand image to be detected and delete button to appear
await waitFor(() => {
expect(screen.getByText('×')).toBeInTheDocument();
});
const deleteButton = screen.getByText('×').closest('button')!;
await userEvent.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('No brand image uploaded')).toBeInTheDocument();
});
await saveAction2!();
expect(deleteBrandImage).toHaveBeenCalledTimes(1);
expect(uploadBrandImage).toHaveBeenCalledTimes(0);
expect(baseProps.setSaveNeeded).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {Client4} from 'mattermost-redux/client';
@ -9,6 +9,7 @@ import {Client4} from 'mattermost-redux/client';
import {uploadBrandImage, deleteBrandImage} from 'actions/admin_actions.jsx';
import SettingSet from 'components/admin_console/setting_set';
import useDidUpdate from 'components/common/hooks/useDidUpdate';
import FormError from 'components/form_error';
import WithTooltip from 'components/with_tooltip';
@ -44,242 +45,224 @@ type Props = {
unRegisterSaveAction: (saveAction: () => Promise<unknown>) => void;
};
type State = {
deleteBrandImage: boolean;
brandImage?: Blob;
brandImageExists: boolean;
brandImageTimestamp: number;
error: string;
};
const BrandImageSetting = ({
id,
disabled,
setSaveNeeded,
registerSaveAction,
unRegisterSaveAction,
}: Props) => {
const imageRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
export default class BrandImageSetting extends React.PureComponent<Props, State> {
private imageRef: React.RefObject<HTMLImageElement>;
private fileInputRef: React.RefObject<HTMLInputElement>;
const [brandImage, setBrandImage] = useState<Blob | undefined>();
const [shouldDeleteBrandImage, setShouldDeleteBrandImage] = useState(false);
const [brandImageExists, setBrandImageExists] = useState(false);
const [brandImageTimestamp, setBrandImageTimestamp] = useState(Date.now());
constructor(props: Props) {
super(props);
const [errorFromState, setErrorFromState] = useState('');
this.state = {
deleteBrandImage: false,
brandImageExists: false,
brandImageTimestamp: Date.now(),
error: '',
};
const handleSave = useCallback(async () => {
setErrorFromState('');
this.imageRef = React.createRef();
this.fileInputRef = React.createRef();
}
let error;
if (shouldDeleteBrandImage) {
await deleteBrandImage(
() => {
setShouldDeleteBrandImage(false);
setBrandImageExists(false);
setBrandImage(undefined);
},
(err: Error) => {
error = err;
setErrorFromState(err.message);
},
);
} else if (brandImage) {
await uploadBrandImage(
brandImage,
() => {
setBrandImageExists(true);
setBrandImage(undefined);
setBrandImageTimestamp(Date.now());
},
(err: Error) => {
error = err;
setErrorFromState(err.message);
},
);
}
return {error};
}, [brandImage, shouldDeleteBrandImage]);
componentDidMount() {
useEffect(() => {
fetch(
Client4.getBrandImageUrl(String(this.state.brandImageTimestamp)),
Client4.getBrandImageUrl(String(Date.now())),
).then((resp) => {
if (resp.status === HTTP_STATUS_OK) {
this.setState({brandImageExists: true});
setBrandImageExists(true);
} else {
this.setState({brandImageExists: false});
setBrandImageExists(false);
}
}).catch((err) => {
console.error(`unable to retrieve brand image: ${err}`); //eslint-disable-line no-console
this.setState({brandImageExists: false});
setBrandImageExists(false);
});
}, []);
this.props.registerSaveAction(this.handleSave);
}
useEffect(() => {
registerSaveAction(handleSave);
componentWillUnmount() {
this.props.unRegisterSaveAction(this.handleSave);
}
return () => {
unRegisterSaveAction(handleSave);
};
}, [handleSave, registerSaveAction, unRegisterSaveAction]);
componentDidUpdate() {
if (this.imageRef.current) {
useDidUpdate(() => {
if (imageRef.current) {
const reader = new FileReader();
const img = this.imageRef.current;
const img = imageRef.current;
reader.onload = (e) => {
const src =
e.target?.result instanceof ArrayBuffer ? e.target?.result.toString() : e.target?.result;
e.target?.result instanceof ArrayBuffer ? e.target?.result.toString() : e.target?.result;
if (src) {
img.setAttribute('src', src);
}
};
if (this.state.brandImage) {
reader.readAsDataURL(this.state.brandImage);
if (brandImage) {
reader.readAsDataURL(brandImage);
}
}
}
}, [brandImage]);
handleSelectClick = () => {
this.fileInputRef.current?.click();
};
const handleSelectClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
handleImageChange = () => {
if (!this.fileInputRef.current) {
const handleImageChange = useCallback(() => {
if (!fileInputRef.current) {
return;
}
const element = this.fileInputRef.current;
const element = fileInputRef.current;
if (element.files && element.files.length > 0) {
this.props.setSaveNeeded();
this.setState({
brandImage: element.files[0],
deleteBrandImage: false,
});
setSaveNeeded();
setBrandImage(element.files[0]);
setShouldDeleteBrandImage(false);
}
};
}, [setSaveNeeded]);
handleDeleteButtonPressed = () => {
this.setState({
deleteBrandImage: true,
brandImage: undefined,
brandImageExists: false,
});
this.props.setSaveNeeded();
};
const handleDeleteButtonPressed = useCallback(() => {
setShouldDeleteBrandImage(true);
setBrandImage(undefined);
setBrandImageExists(false);
handleSave = async () => {
this.setState({
error: '',
});
setSaveNeeded();
}, [setSaveNeeded]);
let error;
if (this.state.deleteBrandImage) {
await deleteBrandImage(
() => {
this.setState({
deleteBrandImage: false,
brandImageExists: false,
brandImage: undefined,
});
},
(err: Error) => {
error = err;
this.setState({
error: err.message,
});
},
);
} else if (this.state.brandImage) {
await uploadBrandImage(
this.state.brandImage,
() => {
this.setState({
brandImageExists: true,
brandImage: undefined,
brandImageTimestamp: Date.now(),
});
},
(err: Error) => {
error = err;
this.setState({
error: err.message,
});
},
);
}
return {error};
};
render() {
let img = null;
if (this.state.brandImage) {
img = (
<div className='remove-image__img mb-5'>
<img
ref={this.imageRef}
alt='brand image'
src=''
/>
</div>
);
} else if (this.state.brandImageExists) {
let overlay;
if (!this.props.disabled) {
overlay = (
<WithTooltip
title={(
<FormattedMessage
id='admin.team.removeBrandImage'
defaultMessage='Remove brand image'
/>
)}
isVertical={false}
>
<button
type='button'
className='remove-image__btn'
onClick={this.handleDeleteButtonPressed}
>
<span aria-hidden={true}>{'×'}</span>
</button>
</WithTooltip>
);
}
img = (
<div className='remove-image__img mb-5'>
<img
alt='brand image'
src={Client4.getBrandImageUrl(
String(this.state.brandImageTimestamp),
)}
/>
{overlay}
</div>
);
} else {
img = (
<p className='mt-2'>
<FormattedMessage
id='admin.team.noBrandImage'
defaultMessage='No brand image uploaded'
/>
</p>
);
}
return (
<SettingSet
inputId={this.props.id}
helpText={
<FormattedMessage
id='admin.team.uploadDesc'
defaultMessage='Customize your user experience by adding a custom image to your login screen. Recommended maximum image size is less than 2 MB.'
/>
}
label={
<FormattedMessage
id='admin.team.brandImageTitle'
defaultMessage='Custom Brand Image:'
/>
}
setByEnv={false}
>
<div>
<div className='remove-image'>{img}</div>
</div>
<div className='file__upload mt-5'>
let img = null;
if (brandImage) {
img = (
<div className='remove-image__img mb-5'>
<img
ref={imageRef}
alt='brand image'
src=''
/>
</div>
);
} else if (brandImageExists) {
let overlay;
if (!disabled) {
overlay = (
<WithTooltip
title={(
<FormattedMessage
id='admin.team.removeBrandImage'
defaultMessage='Remove brand image'
/>
)}
isVertical={false}
>
<button
type='button'
className='btn btn-tertiary'
disabled={this.props.disabled}
onClick={this.handleSelectClick}
className='remove-image__btn'
data-testid='remove-image__btn'
onClick={handleDeleteButtonPressed}
>
<FormattedMessage
id='admin.team.chooseImage'
defaultMessage='Select Image'
/>
<span aria-hidden={true}>{'×'}</span>
</button>
<input
ref={this.fileInputRef}
type='file'
accept={Constants.ACCEPT_STATIC_IMAGE}
disabled={this.props.disabled}
onChange={this.handleImageChange}
/>
</div>
<FormError error={this.state.error}/>
</SettingSet>
</WithTooltip>
);
}
img = (
<div className='remove-image__img mb-5'>
<img
alt='brand image'
src={Client4.getBrandImageUrl(
String(brandImageTimestamp),
)}
/>
{overlay}
</div>
);
} else {
img = (
<p className='mt-2'>
<FormattedMessage
id='admin.team.noBrandImage'
defaultMessage='No brand image uploaded'
/>
</p>
);
}
}
return (
<SettingSet
inputId={id}
helpText={
<FormattedMessage
id='admin.team.uploadDesc'
defaultMessage='Customize your user experience by adding a custom image to your login screen. Recommended maximum image size is less than 2 MB.'
/>
}
label={
<FormattedMessage
id='admin.team.brandImageTitle'
defaultMessage='Custom Brand Image:'
/>
}
setByEnv={false}
>
<div>
<div className='remove-image'>{img}</div>
</div>
<div className='file__upload mt-5'>
<button
type='button'
className='btn btn-tertiary'
disabled={disabled}
onClick={handleSelectClick}
>
<FormattedMessage
id='admin.team.chooseImage'
defaultMessage='Select Image'
/>
</button>
<input
ref={fileInputRef}
data-testid='file__upload-input'
type='file'
accept={Constants.ACCEPT_STATIC_IMAGE}
disabled={disabled}
onChange={handleImageChange}
/>
</div>
<FormError error={errorFromState}/>
</SettingSet>
);
};
export default memo(BrandImageSetting);

View file

@ -237,6 +237,12 @@ const initFormValues = (form: AppForm, timezone?: string): AppFormValues => {
};
export class AppsForm extends React.PureComponent<Props, State> {
// Cache sanitized fields to preserve object identity across renders.
// Without this, createSanitizedField() returns a new object every render,
// causing AppsFormSelectField to remount its AsyncSelect (via refreshNonce),
// which re-triggers dynamic select lookups on every keystroke in any field.
private sanitizedFieldCache = new Map<AppField, AppField>();
constructor(props: Props) {
super(props);
@ -271,6 +277,14 @@ export class AppsForm extends React.PureComponent<Props, State> {
return null;
}
componentDidUpdate(prevProps: Props) {
// Clear sanitized field cache when the form changes (e.g., refresh or multistep)
// so stale entries don't accumulate.
if (prevProps.form !== this.props.form) {
this.sanitizedFieldCache.clear();
}
}
updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => {
let hasErrors = false;
const state = {} as State;
@ -657,8 +671,13 @@ export class AppsForm extends React.PureComponent<Props, State> {
}
return fields.filter((f) => f.name !== form.submit_buttons).map((originalField, index) => {
// Use sanitized field for safe usage in components
const field = createSanitizedField(originalField);
// Use cached sanitized field to preserve object identity across renders.
// This prevents AsyncSelect remounts that trigger spurious lookup calls.
let field = this.sanitizedFieldCache.get(originalField);
if (!field) {
field = createSanitizedField(originalField);
this.sanitizedFieldCache.set(originalField, field);
}
return (
<AppsFormField

View file

@ -4,7 +4,7 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {AppField, AppFormValue, AppSelectOption} from '@mattermost/types/apps';
import {isAppSelectOption, type AppField, type AppFormValue, type AppSelectOption} from '@mattermost/types/apps';
import type {UserAutocomplete} from '@mattermost/types/autocomplete';
import type {Channel} from '@mattermost/types/channels';
@ -176,7 +176,9 @@ export default class AppsFormField extends React.PureComponent<Props> {
);
}
case AppFieldTypes.RADIO: {
const radioValue = value as string;
// Radio values may be stored as AppSelectOption objects (from initial default)
// or plain strings (after user interaction via RadioSetting.onChange)
const radioValue = isAppSelectOption(value) ? value.value : (value as string) ?? '';
return (
<RadioSetting
id={name}

View file

@ -1355,29 +1355,19 @@ describe('components/interactive_dialog/InteractiveDialogAdapter', () => {
const mockCall = MockAppsFormContainer.mock.calls[0][0];
const submitAdapter = mockCall.actions.doAppSubmit;
// Test with null value for required field - should not crash
// Test with null value for required field - should not crash.
// processFormValues normalizes null to '' in accumulatedValues,
// so the validation sees an empty string (not null) and does not
// produce a required-field error.
const result = await submitAdapter({
values: {
'text-field': 'valid',
'required-field': null, // Missing required field
'required-field': null, // Cleared field — normalized to ''
},
});
// Should complete successfully (null values are simply skipped)
// Should complete successfully (null values are normalized to empty strings)
expect(result.data?.type).toBe('ok');
expect(mockConsole.warn).toHaveBeenCalledWith(
'[InteractiveDialogAdapter]',
'Form submission validation errors',
expect.objectContaining({
errorCount: expect.any(Number),
errors: expect.arrayContaining([
expect.objectContaining({
field: expect.stringContaining('required-field'),
message: expect.any(String),
}),
]),
}),
);
});
});
@ -2305,5 +2295,131 @@ describe('components/interactive_dialog/InteractiveDialogAdapter', () => {
data: {items: []},
});
});
test('should include selected_field in refresh submission', async () => {
const mockSubmitDialog = jest.fn().mockResolvedValue({data: {}});
const refreshSelectElement: DialogElement = {
name: 'category',
type: 'select',
display_name: 'Category',
help_text: '',
placeholder: '',
default: '',
optional: false,
max_length: 0,
min_length: 0,
subtype: '',
data_source: '',
options: [
{text: 'Option A', value: 'a'},
{text: 'Option B', value: 'b'},
],
};
const props = {
...baseProps,
sourceUrl: '/plugins/myplugin/refresh',
elements: [refreshSelectElement],
actions: {
submitInteractiveDialog: mockSubmitDialog,
lookupInteractiveDialog: jest.fn().mockResolvedValue({data: {items: []}}),
},
};
const {getByTestId} = renderWithContext(
<InteractiveDialogAdapter {...props}/>,
);
await waitFor(() => {
expect(getByTestId('apps-form-container')).toBeInTheDocument();
});
// Get the refresh handler from the MockAppsFormContainer
const mockCall = MockAppsFormContainer.mock.calls[0][0];
const refreshHandler = mockCall.actions.doAppFetchForm;
// Trigger refresh with selected_field set
await refreshHandler({
selected_field: 'category',
values: {category: 'a'},
});
expect(mockSubmitDialog).toHaveBeenCalledWith(
expect.objectContaining({
url: '/plugins/myplugin/refresh',
submission: expect.objectContaining({
selected_field: 'category',
category: 'a',
}),
}),
);
});
test('should send empty string for cleared select field values in refresh', async () => {
const mockSubmitDialog = jest.fn().mockResolvedValue({data: {}});
const refreshSelectElement: DialogElement = {
name: 'priority',
type: 'select',
display_name: 'Priority',
help_text: '',
placeholder: '',
default: '',
optional: true,
max_length: 0,
min_length: 0,
subtype: '',
data_source: '',
options: [
{text: 'High', value: 'high'},
{text: 'Low', value: 'low'},
],
};
const props = {
...baseProps,
sourceUrl: '/plugins/myplugin/refresh',
elements: [refreshSelectElement],
actions: {
submitInteractiveDialog: mockSubmitDialog,
lookupInteractiveDialog: jest.fn().mockResolvedValue({data: {items: []}}),
},
};
const {getByTestId} = renderWithContext(
<InteractiveDialogAdapter {...props}/>,
);
await waitFor(() => {
expect(getByTestId('apps-form-container')).toBeInTheDocument();
});
// Get the refresh handler from the MockAppsFormContainer
const mockCall = MockAppsFormContainer.mock.calls[0][0];
const refreshHandler = mockCall.actions.doAppFetchForm;
// First refresh sets a value so it gets accumulated
await refreshHandler({
selected_field: 'priority',
values: {priority: 'high'},
});
mockSubmitDialog.mockClear();
// Second refresh clears the field (null simulates a cleared select)
await refreshHandler({
selected_field: 'priority',
values: {priority: null},
});
expect(mockSubmitDialog).toHaveBeenCalledWith(
expect.objectContaining({
submission: expect.objectContaining({
priority: '',
}),
}),
);
});
});
});

View file

@ -169,13 +169,12 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
* Common logic for processing form values - used by both submit and refresh
*/
private processFormValues = (currentValues: Record<string, any>): void => {
// Normalize current values to extract primitive values from select objects
const normalizedCurrentValues = extractPrimitiveValues(currentValues);
// Accumulate values: merge current with existing accumulated values
// Normalize current values to extract primitive values from select objects.
// clearEmptyFields=true ensures cleared fields emit '' or [] so they
// overwrite any previously accumulated value for that key.
this.accumulatedValues = {
...this.accumulatedValues, // Previous steps' values (including other pages)
...normalizedCurrentValues, // Current form normalized values
...this.accumulatedValues,
...extractPrimitiveValues(currentValues, true),
};
};
@ -533,8 +532,15 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
const currentValues = call.values || {};
this.processFormValues(currentValues);
// For refresh, send all accumulated normalized values
const refreshPayload = this.accumulatedValues;
// For refresh, use a shallow copy so that adding selected_field
// does not permanently contaminate this.accumulatedValues
const refreshPayload = {...this.accumulatedValues};
// Include the changed field name in the submission so the plugin
// knows which field triggered the refresh
if (call.selected_field) {
refreshPayload.selected_field = call.selected_field;
}
const refreshSubmission: DialogSubmission = {
url: this.props.sourceUrl,

View file

@ -408,6 +408,25 @@ describe('components/login/Login', () => {
expect(history.push).toHaveBeenCalledWith(redirectPath);
});
it('should not show dont have an account when open server is disabled', () => {
const state = mergeObjects(baseState, {
entities: {
general: {
config: {
EnableOpenServer: 'false',
},
},
},
});
renderWithContext(
<Login/>,
state,
);
expect(screen.queryByText('Don\'t have an account')).not.toBeInTheDocument();
});
describe('EnableGuestMagicLink', () => {
it('should show password field when EnableGuestMagicLink is false', async () => {
const state = mergeObjects(baseState, {

View file

@ -437,23 +437,18 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
}, [sessionExpired, formatMessage, onDismissSessionExpired, extraParam, siteName, searchParam]);
const getAlternateLink = useCallback(() => {
if (!showSignup) {
return undefined;
}
const linkLabel = formatMessage({
id: 'login.noAccount',
defaultMessage: 'Don\'t have an account?',
});
if (showSignup) {
return (
<AlternateLinkLayout
className='login-body-alternate-link'
alternateLinkPath={'/signup_user_complete'}
alternateLinkLabel={linkLabel}
/>
);
}
return (
<AlternateLinkLayout
className='login-body-alternate-link'
alternateLinkPath={'/access_problem'}
alternateLinkPath={'/signup_user_complete'}
alternateLinkLabel={linkLabel}
/>
);

View file

@ -50,7 +50,6 @@ const MobileViewWatcher = makeAsyncComponent('MobileViewWatcher', lazy(() => imp
const WindowSizeObserver = makeAsyncComponent('WindowSizeObserver', lazy(() => import('components/window_size_observer/WindowSizeObserver')));
const ErrorPage = makeAsyncComponent('ErrorPage', lazy(() => import('components/error_page')));
const Login = makeAsyncComponent('LoginController', lazy(() => import('components/login/login')));
const AccessProblem = makeAsyncComponent('AccessProblem', lazy(() => import('components/access_problem')));
const PasswordResetSendLink = makeAsyncComponent('PasswordResedSendLink', lazy(() => import('components/password_reset_send_link')));
const PasswordResetForm = makeAsyncComponent('PasswordResetForm', lazy(() => import('components/password_reset_form')));
const Signup = makeAsyncComponent('SignupController', lazy(() => import('components/signup/signup')));
@ -320,10 +319,6 @@ export default class Root extends React.PureComponent<Props, State> {
path={'/login'}
component={Login}
/>
<HFRoute
path={'/access_problem'}
component={AccessProblem}
/>
<HFTRoute
path={'/reset_password'}
component={PasswordResetSendLink}

View file

@ -5143,8 +5143,6 @@
"login.cardtitle": "Log in",
"login.cardtitle.external": "Log in with one of the following:",
"login.changed": "Sign-in method changed successfully",
"login.contact_admin.detail": "To access your team's workspace, contact your workspace admin. If you've been invited already, check your email inbox for a Mattermost workspace invite.",
"login.contact_admin.title": "Contact your workspace admin",
"login.createTeam": "Create a team",
"login.deactivatedUser": "Your account has been deactivated.",
"login.defaultError": "We were unable to log you in. Please enter your details and try again.",

View file

@ -8,6 +8,7 @@ import {
convertDialogToAppForm,
convertAppFormValuesToDialogSubmission,
DialogElementTypes,
extractPrimitiveValues,
getDefaultValue,
getFieldType,
getOptions,
@ -335,10 +336,24 @@ describe('dialog_conversion', () => {
} as DialogElement;
const result = getDefaultValue(element);
expect(result).toEqual({
label: 'Option 1',
value: 'option1',
});
// Radio defaults are plain strings (not {label, value} objects)
// because RadioSetting.onChange returns e.target.value (a string)
expect(result).toBe('option1');
});
it('should return null for radio default that does not match any option', () => {
const element = {
type: 'radio',
default: 'stale_value',
options: [
{text: 'Option 1', value: 'option1'},
{text: 'Option 2', value: 'option2'},
],
} as DialogElement;
const result = getDefaultValue(element);
expect(result).toBeNull();
});
it('should handle dynamic select defaults', () => {
@ -973,6 +988,48 @@ describe('dialog_conversion', () => {
expect(form.fields?.[0].refresh).toBe(true);
});
it('should handle refresh property for date and datetime fields', () => {
const elements: DialogElement[] = [
{
name: 'refreshable_date',
type: 'date',
display_name: 'Refreshable Date',
optional: false,
refresh: true,
} as DialogElement,
{
name: 'refreshable_datetime',
type: 'datetime',
display_name: 'Refreshable Datetime',
optional: false,
refresh: true,
} as DialogElement,
{
name: 'non_refreshable_date',
type: 'date',
display_name: 'Non-Refreshable Date',
optional: false,
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com/source',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.fields).toHaveLength(3);
expect(form.fields?.[0].refresh).toBe(true);
expect(form.fields?.[1].refresh).toBe(true);
expect(form.fields?.[2].refresh).toBeUndefined();
});
it('should include state in submit and source AppCall objects', () => {
const elements: DialogElement[] = [
{
@ -1234,6 +1291,48 @@ describe('dialog_conversion', () => {
});
});
it('should extract value from radio field stored as AppSelectOption object', () => {
const values = {
radio_object: {label: 'Option A', value: 'optA'},
radio_string: 'optB',
} as unknown as AppFormValues;
const elements: DialogElement[] = [
{
name: 'radio_object',
type: 'radio',
display_name: 'Radio Object Field',
optional: false,
options: [
{text: 'Option A', value: 'optA'},
{text: 'Option B', value: 'optB'},
],
} as DialogElement,
{
name: 'radio_string',
type: 'radio',
display_name: 'Radio String Field',
optional: false,
options: [
{text: 'Option A', value: 'optA'},
{text: 'Option B', value: 'optB'},
],
} as DialogElement,
];
const {submission, errors} = convertAppFormValuesToDialogSubmission(
values,
elements,
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(submission).toEqual({
radio_object: 'optA',
radio_string: 'optB',
});
});
it('should handle textarea field values', () => {
const values = {
textarea_field: 'Long text content',
@ -1682,4 +1781,154 @@ describe('dialog_conversion', () => {
});
});
});
describe('extractPrimitiveValues', () => {
it('should extract value from a single select option', () => {
const result = extractPrimitiveValues({
color: {label: 'Red', value: 'red'},
});
expect(result).toEqual({color: 'red'});
});
it('should extract values from a multiselect array', () => {
const result = extractPrimitiveValues({
colors: [
{label: 'Red', value: 'red'},
{label: 'Blue', value: 'blue'},
],
});
expect(result).toEqual({colors: ['red', 'blue']});
});
it('should pass through primitive strings', () => {
const result = extractPrimitiveValues({
name: 'hello',
});
expect(result).toEqual({name: 'hello'});
});
it('should pass through booleans', () => {
const result = extractPrimitiveValues({
enabled: true,
disabled: false,
});
expect(result).toEqual({enabled: true, disabled: false});
});
it('should skip null, undefined, empty string, and <nil> values', () => {
const result = extractPrimitiveValues({
a: null,
b: undefined,
c: '',
d: '<nil>',
e: 'keep',
});
expect(result).toEqual({e: 'keep'});
});
it('should skip select option with empty value', () => {
const result = extractPrimitiveValues({
color: {label: '', value: ''},
});
expect(result).toEqual({});
});
it('should skip select option with <nil> value', () => {
const result = extractPrimitiveValues({
color: {label: 'None', value: '<nil>'},
});
expect(result).toEqual({});
});
it('should skip empty multiselect arrays', () => {
const result = extractPrimitiveValues({
colors: [],
});
expect(result).toEqual({});
});
it('should pass through primitive string arrays', () => {
const result = extractPrimitiveValues({
dates: ['2026-01-01', '2026-01-15'],
});
expect(result).toEqual({dates: ['2026-01-01', '2026-01-15']});
});
it('should handle mixed arrays of select options and primitives', () => {
const result = extractPrimitiveValues({
items: [
{label: 'Red', value: 'red'},
'already-extracted',
],
});
expect(result).toEqual({items: ['red', 'already-extracted']});
});
it('should filter out meaningless values from multiselect arrays', () => {
const result = extractPrimitiveValues({
colors: [
{label: 'Red', value: 'red'},
{label: 'Empty', value: ''},
{label: 'Blue', value: 'blue'},
],
});
expect(result).toEqual({colors: ['red', 'blue']});
});
describe('with clearEmptyFields=true', () => {
it('should emit empty string for null values', () => {
const result = extractPrimitiveValues({a: null}, true);
expect(result).toEqual({a: ''});
});
it('should emit empty string for undefined values', () => {
const result = extractPrimitiveValues({a: undefined}, true);
expect(result).toEqual({a: ''});
});
it('should emit empty string for empty string values', () => {
const result = extractPrimitiveValues({a: ''}, true);
expect(result).toEqual({a: ''});
});
it('should emit empty string for <nil> values', () => {
const result = extractPrimitiveValues({a: '<nil>'}, true);
expect(result).toEqual({a: ''});
});
it('should emit empty array for empty multiselect arrays', () => {
const result = extractPrimitiveValues({colors: []}, true);
expect(result).toEqual({colors: []});
});
it('should emit empty string for select option with empty value', () => {
const result = extractPrimitiveValues({
color: {label: '', value: ''},
}, true);
expect(result).toEqual({color: ''});
});
it('should emit empty string for select option with <nil> value', () => {
const result = extractPrimitiveValues({
color: {label: 'None', value: '<nil>'},
}, true);
expect(result).toEqual({color: ''});
});
it('should still extract meaningful values normally', () => {
const result = extractPrimitiveValues({
name: 'hello',
color: {label: 'Red', value: 'red'},
cleared: null,
emptied: [],
}, true);
expect(result).toEqual({
name: 'hello',
color: 'red',
cleared: '',
emptied: [],
});
});
});
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AppForm, AppField, AppFormValue, AppSelectOption, AppFormValues} from '@mattermost/types/apps';
import {isAppSelectOption, type AppForm, type AppField, type AppFormValue, type AppSelectOption, type AppFormValues} from '@mattermost/types/apps';
import type {DialogElement} from '@mattermost/types/integrations';
import {AppFieldTypes} from 'mattermost-redux/constants/apps';
@ -253,10 +253,19 @@ export function getDefaultValue(element: DialogElement): AppFormValue {
return boolString === 'true' || boolString === '1' || boolString === 'yes';
}
case DialogElementTypes.SELECT:
case DialogElementTypes.RADIO: {
// Radio values are always plain strings (RadioSetting.onChange returns e.target.value).
// Normalize to string from the start so the value shape never changes after user interaction.
if (element.options && element.default) {
const match = element.options.find((opt) => opt.value === element.default);
return match ? match.value : null;
}
return element.default ? String(element.default) : null;
}
case DialogElementTypes.SELECT: {
// Handle dynamic selects that use data_source instead of static options
if (element.type === 'select' && element.data_source === 'dynamic' && element.default) {
if (element.data_source === 'dynamic' && element.default) {
if (element.multiselect) {
const values = Array.isArray(element.default) ?
element.default :
@ -274,7 +283,7 @@ export function getDefaultValue(element: DialogElement): AppFormValue {
if (element.options && element.default) {
// Handle multiselect defaults (comma-separated values)
if (element.type === 'select' && element.multiselect) {
if (element.multiselect) {
const defaultValues = Array.isArray(element.default) ? element.default : String(element.default).split(',').map((val) => val.trim());
const defaultOptions = defaultValues.map((value) => {
@ -439,6 +448,13 @@ export function convertElement(element: DialogElement, options: ConversionOption
}
}
// Add refresh support for bool fields
if (element.type === DialogElementTypes.BOOL) {
if (element.refresh !== undefined) {
appField.refresh = element.refresh;
}
}
// Add date/datetime specific properties
if (element.type === DialogElementTypes.DATE || element.type === DialogElementTypes.DATETIME) {
// Use datetime_config if provided
@ -456,6 +472,10 @@ export function convertElement(element: DialogElement, options: ConversionOption
if (element.time_interval !== undefined && element.type === DialogElementTypes.DATETIME) {
appField.time_interval = Number(element.time_interval);
}
if (element.refresh !== undefined) {
appField.refresh = element.refresh;
}
}
return {field: appField, errors};
@ -567,42 +587,34 @@ export function convertDialogToAppForm(
/**
* Extract primitive values from form field objects for storage/submission
* Converts select option objects {label: "Text", value: "val"} to primitive "val"
* Filters out null, undefined, empty, and "<nil>" values
* Filters out null, undefined, empty, and "<nil>" values unless clearEmptyFields is true,
* in which case empty/null fields are emitted as '' or [] so they can overwrite prior values.
*/
export function extractPrimitiveValues(values: Record<string, any>): Record<string, any> {
const normalized: Record<string, any> = {};
Object.entries(values).forEach(([key, value]) => {
// Skip null, undefined, empty string, and "<nil>" values
if (value === null || value === undefined || value === '' || value === '<nil>') {
return;
}
export function extractPrimitiveValues(values: Record<string, any>, clearEmptyFields = false): Record<string, any> {
const isMeaningful = (v: any): v is string | boolean => v != null && v !== '' && v !== '<nil>';
return Object.entries(values).reduce<Record<string, any>>((acc, [key, value]) => {
if (Array.isArray(value)) {
// Handle multiselect arrays - extract values from each option object
const extractedValues = value.
filter((item) => item && typeof item === 'object' && 'value' in item).
map((item) => item.value).
filter((val) => val !== null && val !== undefined && val !== '' && val !== '<nil>');
const extracted = value.
map((item) => (isAppSelectOption(item) ? item.value : item)).
filter(isMeaningful);
if (extractedValues.length > 0) {
normalized[key] = extractedValues;
if (extracted.length > 0 || clearEmptyFields) {
acc[key] = extracted;
}
} else if (value && typeof value === 'object' && 'value' in value) {
// Extract value from single select option object {label: "...", value: "..."}
const extractedValue = value.value;
// Only store if the extracted value is meaningful
if (extractedValue !== null && extractedValue !== undefined && extractedValue !== '' && extractedValue !== '<nil>') {
normalized[key] = extractedValue;
} else if (isAppSelectOption(value)) {
if (isMeaningful(value.value)) {
acc[key] = value.value;
} else if (clearEmptyFields) {
acc[key] = '';
}
} else {
// Keep primitive values as-is (but skip empty/nil values)
normalized[key] = value;
} else if (isMeaningful(value)) {
acc[key] = value;
} else if (clearEmptyFields) {
acc[key] = '';
}
});
return normalized;
return acc;
}, {});
}
/**
@ -693,7 +705,13 @@ export function convertAppFormValuesToDialogSubmission(
break;
case DialogElementTypes.RADIO:
submission[element.name] = String(value);
// Radio values are normally plain strings, but accept {label, value}
// objects for backwards compatibility with older code paths.
if (isAppSelectOption(value)) {
submission[element.name] = value.value;
} else {
submission[element.name] = String(value);
}
break;
case DialogElementTypes.SELECT:

View file

@ -417,7 +417,7 @@ export type AppSelectOption = {
icon_data?: string;
};
function isAppSelectOption(v: unknown): v is AppSelectOption {
export function isAppSelectOption(v: unknown): v is AppSelectOption {
if (typeof v !== 'object' || v === null) {
return false;
}