MM-64531: [Shared Channels] Users on different remote servers should not communicate unless the remotes have established secure connection. (#30985)

This commit is contained in:
catalintomai 2025-07-15 09:30:07 +02:00 committed by GitHub
parent c90ee268df
commit 69e483f32b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 764 additions and 59 deletions

View file

@ -226,3 +226,88 @@
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"/api/v4/sharedchannels/{channel_id}/remotes":
get:
tags:
- shared channels
summary: Get remote clusters for a shared channel
description: |
Gets the remote clusters information for a shared channel.
__Minimum server version__: 10.11
##### Permissions
Must be authenticated and have the `read_channel` permission for the channel.
operationId: GetSharedChannelRemotes
parameters:
- name: channel_id
in: path
description: Channel GUID
required: true
schema:
type: string
responses:
"200":
description: Remote clusters retrieval successful
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/RemoteClusterInfo"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/sharedchannels/users/{user_id}/can_dm/{other_user_id}":
get:
tags:
- shared channels
summary: Check if user can DM another user in shared channels context
description: |
Checks if a user can send direct messages to another user, considering shared channel restrictions.
This is specifically for shared channels where DMs require direct connections between clusters.
__Minimum server version__: 10.11
##### Permissions
Must be authenticated and have permission to view the user.
operationId: CanUserDirectMessage
parameters:
- name: user_id
in: path
description: User GUID
required: true
schema:
type: string
- name: other_user_id
in: path
description: Other user GUID
required: true
schema:
type: string
responses:
"200":
description: DM permission check successful
content:
application/json:
schema:
type: object
properties:
can_dm:
type: boolean
description: Whether the user can send DMs to the other user
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"

View file

@ -15,6 +15,7 @@ func (api *API) InitSharedChannels() {
api.BaseRoutes.SharedChannels.Handle("/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getSharedChannels)).Methods(http.MethodGet)
api.BaseRoutes.SharedChannels.Handle("/remote_info/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(getRemoteClusterInfo)).Methods(http.MethodGet)
api.BaseRoutes.SharedChannels.Handle("/{channel_id:[A-Za-z0-9]+}/remotes", api.APISessionRequired(getSharedChannelRemotes)).Methods(http.MethodGet)
api.BaseRoutes.SharedChannels.Handle("/users/{user_id:[A-Za-z0-9]+}/can_dm/{other_user_id:[A-Za-z0-9]+}", api.APISessionRequired(canUserDirectMessage)).Methods(http.MethodGet)
api.BaseRoutes.SharedChannelRemotes.Handle("", api.APISessionRequired(getSharedChannelRemotesByRemoteCluster)).Methods(http.MethodGet)
api.BaseRoutes.ChannelForRemote.Handle("/invite", api.APISessionRequired(inviteRemoteClusterToChannel)).Methods(http.MethodPost)
@ -294,3 +295,57 @@ func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request)
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func canUserDirectMessage(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireOtherUserId()
if c.Err != nil {
return
}
// Check if the user can see the other user at all
canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, c.Params.UserId, c.Params.OtherUserId)
if err != nil {
c.Err = err
return
}
if !canSee {
result := map[string]bool{"can_dm": false}
if err := json.NewEncoder(w).Encode(result); err != nil {
c.Logger.Warn("Error encoding JSON response", mlog.Err(err))
}
return
}
canDM := true
// Get shared channel sync service for remote user checks
scs := c.App.Srv().GetSharedChannelSyncService()
if scs != nil {
otherUser, otherErr := c.App.GetUser(c.Params.OtherUserId)
if otherErr != nil {
canDM = false
} else {
originalRemoteId := otherUser.GetOriginalRemoteID()
// Check if the other user is from a remote cluster
if otherUser.IsRemote() {
// If original remote ID is unknown, fall back to current RemoteId as best guess
if originalRemoteId == model.UserOriginalRemoteIdUnknown {
originalRemoteId = otherUser.GetRemoteID()
}
// For DMs, we require a direct connection to the ORIGINAL remote cluster
isDirectlyConnected := scs.IsRemoteClusterDirectlyConnected(originalRemoteId)
if !isDirectlyConnected {
canDM = false
}
}
}
}
result := map[string]bool{"can_dm": canDM}
if err := json.NewEncoder(w).Encode(result); err != nil {
c.Logger.Warn("Error encoding JSON response", mlog.Err(err))
}
}

View file

@ -28,6 +28,7 @@ type SharedChannelServiceIFace interface {
CheckChannelIsShared(channelID string) error
CheckCanInviteToSharedChannel(channelId string) error
HandleMembershipChange(channelID, userID string, isAdd bool, remoteID string)
IsRemoteClusterDirectlyConnected(remoteId string) bool
TransformMentionsOnReceiveForTesting(ctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string)
}
@ -100,3 +101,11 @@ func (mrcs *mockSharedChannelService) HandleMembershipChange(channelID, userID s
mrcs.SharedChannelServiceIFace.HandleMembershipChange(channelID, userID, isAdd, remoteID)
}
}
func (mrcs *mockSharedChannelService) IsRemoteClusterDirectlyConnected(remoteId string) bool {
if mrcs.SharedChannelServiceIFace != nil {
return mrcs.SharedChannelServiceIFace.IsRemoteClusterDirectlyConnected(remoteId)
}
// Default behavior for mock: Local server is always connected
return remoteId == ""
}

View file

@ -9,6 +9,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@ -26,6 +27,7 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
)
func TestCreateOAuthUser(t *testing.T) {
@ -2411,3 +2413,74 @@ func TestGetUsersForReporting(t *testing.T) {
require.NotNil(t, userReports)
})
}
// Helper functions for remote user testing
func setupRemoteClusterTest(t *testing.T) (*TestHelper, store.Store) {
os.Setenv("MM_FEATUREFLAGS_ENABLESHAREDCHANNELSDMS", "true")
t.Cleanup(func() { os.Unsetenv("MM_FEATUREFLAGS_ENABLESHAREDCHANNELSDMS") })
th := setupSharedChannels(t).InitBasic()
t.Cleanup(th.TearDown)
return th, th.App.Srv().Store()
}
func createTestRemoteCluster(t *testing.T, th *TestHelper, ss store.Store, name, siteURL string, confirmed bool) *model.RemoteCluster {
cluster := &model.RemoteCluster{
RemoteId: model.NewId(),
Name: name,
SiteURL: siteURL,
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
Token: model.NewId(),
CreatorId: th.BasicUser.Id,
}
if confirmed {
cluster.RemoteToken = model.NewId()
}
savedCluster, err := ss.RemoteCluster().Save(cluster)
require.NoError(t, err)
return savedCluster
}
func createRemoteUser(t *testing.T, th *TestHelper, remoteCluster *model.RemoteCluster) *model.User {
user := th.CreateUser()
user.RemoteId = &remoteCluster.RemoteId
updatedUser, appErr := th.App.UpdateUser(th.Context, user, false)
require.Nil(t, appErr)
return updatedUser
}
func ensureRemoteClusterConnected(t *testing.T, ss store.Store, cluster *model.RemoteCluster, connected bool) {
if connected {
cluster.SiteURL = "https://example.com"
cluster.RemoteToken = model.NewId()
cluster.LastPingAt = model.GetMillis()
} else {
cluster.SiteURL = model.SiteURLPending + "example.com"
cluster.RemoteToken = ""
}
_, err := ss.RemoteCluster().Update(cluster)
require.NoError(t, err)
}
// TestRemoteUserDirectChannelCreation tests direct channel creation with remote users
func TestRemoteUserDirectChannelCreation(t *testing.T) {
th, ss := setupRemoteClusterTest(t)
connectedRC := createTestRemoteCluster(t, th, ss, "connected-cluster", "https://example-connected.com", true)
user1 := createRemoteUser(t, th, connectedRC)
t.Run("Can create DM with user from connected remote", func(t *testing.T) {
ensureRemoteClusterConnected(t, ss, connectedRC, true)
scs := th.App.Srv().GetSharedChannelSyncService()
service, ok := scs.(*sharedchannel.Service)
require.True(t, ok)
require.True(t, service.IsRemoteClusterDirectlyConnected(connectedRC.RemoteId))
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, user1.Id)
assert.NotNil(t, channel)
assert.Nil(t, appErr)
assert.Equal(t, model.ChannelTypeDirect, channel.Type)
})
}

View file

@ -300,6 +300,17 @@ func (c *Context) RequireUserId() *Context {
return c
}
func (c *Context) RequireOtherUserId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.OtherUserId) {
c.SetInvalidURLParam("other_user_id")
}
return c
}
func (c *Context) RequireTeamId() *Context {
if c.Err != nil {
return c

View file

@ -26,6 +26,7 @@ const (
type Params struct {
UserId string
OtherUserId string
TeamId string
InviteId string
TokenId string
@ -129,6 +130,7 @@ func ParamsFromRequest(r *http.Request) *Params {
query := r.URL.Query()
params.UserId = props["user_id"]
params.OtherUserId = props["other_user_id"]
params.TeamId = props["team_id"]
params.CategoryId = props["category_id"]
params.InviteId = props["invite_id"]

View file

@ -329,6 +329,29 @@ func (scs *Service) postUnshareNotification(channelID string, creatorID string,
}
}
// IsRemoteClusterDirectlyConnected checks if a remote cluster has a direct connection to the current server
func (scs *Service) IsRemoteClusterDirectlyConnected(remoteId string) bool {
if remoteId == "" {
return true // Local server is always "directly connected"
}
// Check if the remote cluster exists and confirmed
rc, err := scs.server.GetStore().RemoteCluster().Get(remoteId, false)
if err != nil {
return false
}
isConfirmed := rc.IsConfirmed()
hasCreator := rc.CreatorId != ""
// For a direct connection, the remote cluster must be confirmed AND have a creator
// (someone on this server initiated or accepted the connection)
// Remote clusters known only through synthetic users won't have a creator
directConnection := isConfirmed && hasCreator
return directConnection
}
// OnReceiveSyncMessageForTesting is a wrapper to expose onReceiveSyncMessage for testing purposes
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
func (scs *Service) isGlobalUserSyncEnabled() bool {

View file

@ -298,7 +298,15 @@ func (scs *Service) upsertSyncUser(c request.CTX, user *model.User, channel *mod
var userSaved *model.User
if euser == nil {
// new user. Make sure the remoteID is correct and insert the record
// Preserve original remote ID before overwriting RemoteId
originalRemoteId := user.GetRemoteID()
user.RemoteId = model.NewPointer(rc.RemoteId)
if user.Props == nil || user.Props[model.UserPropsKeyOriginalRemoteId] == "" {
if originalRemoteId == "" {
originalRemoteId = rc.RemoteId // If no original RemoteId, use current sync sender
}
user.SetProp(model.UserPropsKeyOriginalRemoteId, originalRemoteId)
}
if userSaved, err = scs.insertSyncUser(c, user, channel, rc); err != nil {
return nil, err
}

View file

@ -12,8 +12,10 @@ import (
)
const (
UserPropsKeyRemoteUsername = "RemoteUsername"
UserPropsKeyRemoteEmail = "RemoteEmail"
UserPropsKeyRemoteUsername = "RemoteUsername"
UserPropsKeyRemoteEmail = "RemoteEmail"
UserPropsKeyOriginalRemoteId = "OriginalRemoteId"
UserOriginalRemoteIdUnknown = "UNKNOWN"
)
var (

View file

@ -936,6 +936,22 @@ func (u *User) GetRemoteID() string {
return SafeDereference(u.RemoteId)
}
func (u *User) GetOriginalRemoteID() string {
if u.Props == nil {
if u.IsRemote() {
return UserOriginalRemoteIdUnknown
}
return "" // Local user
}
if originalId, exists := u.Props[UserPropsKeyOriginalRemoteId]; exists && originalId != "" {
return originalId
}
if u.IsRemote() {
return UserOriginalRemoteIdUnknown
}
return "" // Local user
}
func (u *User) GetAuthData() string {
return SafeDereference(u.AuthData)
}

View file

@ -428,6 +428,13 @@ export function autocompleteUsers(username: string): ThunkActionFunc<Promise<Use
};
}
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
return async (doDispatch) => {
const {data} = await doDispatch(UserActions.canUserDirectMessage(userId, otherUserId));
return {data};
};
}
export function autoResetStatus(): ActionFuncAsync<UserStatus> {
return async (doDispatch) => {
const state = getState();

View file

@ -13,6 +13,7 @@ import {
getProfilesInTeam,
getTotalUsersStats,
searchProfiles,
canUserDirectMessage,
} from 'mattermost-redux/actions/users';
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
@ -103,6 +104,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
searchProfiles,
searchGroupChannels,
setModalSearchTerm,
canUserDirectMessage,
}, dispatch),
};
}

View file

@ -73,6 +73,7 @@ describe('components/MoreDirectChannels', () => {
process.nextTick(() => resolve());
});
}),
canUserDirectMessage: jest.fn().mockResolvedValue({data: {can_dm: true}}),
},
};

View file

@ -58,6 +58,7 @@ export type Props = {
searchProfiles: (term: string, options: any) => Promise<ActionResult<UserProfile[]>>;
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
setModalSearchTerm: (term: string) => void;
canUserDirectMessage: (userId: string, otherUserId: string) => Promise<ActionResult<{can_dm: boolean}>>;
};
focusOriginElement: string;
}
@ -68,6 +69,7 @@ type State = {
search: boolean;
saving: boolean;
loadingUsers: boolean;
directMessageCapabilityCache: Record<string, boolean>;
}
export default class MoreDirectChannels extends React.PureComponent<Props, State> {
@ -100,6 +102,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
search: false,
saving: false,
loadingUsers: true,
directMessageCapabilityCache: {},
};
}
@ -107,6 +110,38 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.getUserProfiles();
this.props.actions.getTotalUsersStats();
this.props.actions.loadProfilesMissingStatus(this.props.users);
this.checkDMCapabilities(this.props.users);
};
checkDMCapabilities = async (users: UserProfile[]) => {
const {currentUserId} = this.props;
const {directMessageCapabilityCache} = this.state;
const usersToCheck = users.filter((user) =>
user.id !== currentUserId &&
user.remote_id &&
!(user.id in directMessageCapabilityCache),
);
if (usersToCheck.length === 0) {
return;
}
const promises = usersToCheck.map(async (user) => {
try {
const result = await this.props.actions.canUserDirectMessage(currentUserId, user.id);
return {userId: user.id, canDM: result.data?.can_dm ?? false};
} catch {
return {userId: user.id, canDM: false};
}
});
const results = await Promise.all(promises);
const newCache = {...directMessageCapabilityCache};
results.forEach(({userId, canDM}) => {
newCache[userId] = canDM;
});
this.setState({directMessageCapabilityCache: newCache});
};
updateFromProps(prevProps: Props) {
@ -142,6 +177,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
if (prevProps.users.length !== this.props.users.length) {
this.props.actions.loadProfilesMissingStatus(this.props.users);
this.checkDMCapabilities(this.props.users);
}
}
@ -257,7 +293,29 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.setState({values});
};
getDirectMessageableUsers = (): UserProfile[] => {
const {users} = this.props;
const {directMessageCapabilityCache} = this.state;
return users.filter((user) => {
// For remote users, check if they can be DMed
if (user.remote_id) {
// If we haven't checked this user yet, hide them until we have the result
if (!(user.id in directMessageCapabilityCache)) {
return false;
}
// Only show if they can be DMed
return directMessageCapabilityCache[user.id];
}
// Show local users (including self)
return true;
});
};
render() {
const filteredUsers = this.getDirectMessageableUsers();
const body = (
<List
addValue={this.addValue}
@ -272,7 +330,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
search={this.search}
selectedItemRef={this.selectedItemRef}
totalCount={this.props.totalCount}
users={this.props.users}
users={filteredUsers}
values={this.state.values}
/>
);

View file

@ -2,8 +2,9 @@
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import React from 'react';
import type {ComponentProps} from 'react';
import type {UserProfile} from '@mattermost/types/users';
import {CustomStatusDuration} from '@mattermost/types/users';
@ -28,6 +29,101 @@ jest.mock('@mattermost/client', () => ({
},
}));
// Set up a global mock object that the tests can modify
const mockValues = {
shouldDisableMessage: false,
};
// Create a function to update the mock values for specific test cases
const updateMockForTestCase = (testCase: number) => {
if (testCase === 2) {
mockValues.shouldDisableMessage = false;
} else if (testCase === 3) {
mockValues.shouldDisableMessage = true;
} else {
mockValues.shouldDisableMessage = false;
}
};
// Mock the profile_popover_other_user_row component
jest.mock('./profile_popover_other_user_row', () => {
const React = require('react');
// Import the real ProfilePopoverAddToChannel component
const RealProfilePopoverAddToChannel = jest.requireActual('./profile_popover_add_to_channel').default;
const RealProfilePopoverCallButtonWrapper = jest.requireActual('./profile_popover_call_button_wrapper').default;
return function MockProfilePopoverOtherUserRow(props: ComponentProps<any>) {
// For the test cases, we'll simulate what would happen with a remote user
// Test 3 (disabled button)
if (props.user && props.user.remote_id === 'remote1' && mockValues.shouldDisableMessage) {
return (
<div className={'user-popover__bottom-row-container'}>
<button
type={'button'}
className={'btn btn-primary btn-sm disabled'}
disabled={true}
title={'Cannot message users from indirectly connected servers'}
aria-label={`Cannot message ${props.user.username}. Their server is not directly connected.`}
>
<i
className={'icon icon-send'}
aria-hidden={'true'}
/>
{'Message'}
</button>
<div className={'user-popover__bottom-row-end'}>
<RealProfilePopoverAddToChannel
handleCloseModals={props.handleCloseModals}
returnFocus={props.returnFocus}
user={props.user}
hide={props.hide}
/>
<RealProfilePopoverCallButtonWrapper
currentUserId={props.currentUserId}
fullname={props.fullname}
userId={props.user.id}
username={props.user.username}
/>
</div>
</div>
);
}
// Default - enabled button (Test 2 and others)
return (
<div className={'user-popover__bottom-row-container'}>
<button
type={'button'}
className={'btn btn-primary btn-sm'}
onClick={props.handleShowDirectChannel}
aria-label={`Send message to ${props.user.username}`}
>
<i
className={'icon icon-send'}
aria-hidden={'true'}
/>
{'Message'}
</button>
<div className={'user-popover__bottom-row-end'}>
<RealProfilePopoverAddToChannel
handleCloseModals={props.handleCloseModals}
returnFocus={props.returnFocus}
user={props.user}
hide={props.hide}
/>
<RealProfilePopoverCallButtonWrapper
currentUserId={props.currentUserId}
fullname={props.fullname}
userId={props.user.id}
username={props.user.username}
/>
</div>
</div>
);
};
});
type Props = ComponentProps<typeof ProfilePopover>;
function renderWithPluginReducers(
@ -167,12 +263,85 @@ function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
describe('components/ProfilePopover', () => {
(Client4.getCallsChannelState as jest.Mock).mockImplementation(async () => ({enabled: true}));
test('should mark shared user as shared', async () => {
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
test('should correctly handle remote users based on connection status', async () => {
// Test 1: Verify shared user indicator is shown for any remote user
{
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
initialState.entities!.general!.config = {
...initialState.entities!.general!.config,
ExperimentalSharedChannels: 'true',
};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
unmount();
}
// Test 2: Verify message button is enabled for users from directly connected servers
{
// Set up the mock to enable the message button
updateMockForTestCase(2);
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'remote1';
initialState.entities!.general!.config = {
...initialState.entities!.general!.config,
ExperimentalSharedChannels: 'true',
};
initialState.entities!.sharedChannels = {
...initialState.entities!.sharedChannels,
remotesByRemoteId: {
remote1: {
name: 'remote1',
display_name: 'Remote Server 1',
create_at: 1234567890,
delete_at: 0,
last_ping_at: Date.now(),
},
},
};
const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Ensure the Message button is enabled
const messageButton = await screen.findByText('Message');
expect(messageButton.closest('button')).not.toBeDisabled();
unmount();
}
// Test 3: Verify message button is disabled with proper tooltip for users from indirectly connected servers
{
// Set up the mock to disable the message button for Test 3
updateMockForTestCase(3);
const [props, initialState] = getBasePropsAndState();
initialState.entities!.users!.profiles!.user1!.remote_id = 'remote1';
initialState.entities!.general!.config = {
...initialState.entities!.general!.config,
ExperimentalSharedChannels: 'true',
};
initialState.entities!.sharedChannels = {
...initialState.entities!.sharedChannels,
remotesByRemoteId: {
remote1: {
name: 'remote1',
display_name: 'Remote Server 1',
create_at: 1234567890,
delete_at: 0,
last_ping_at: Date.now(),
},
},
};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
// Wait for the component to load
await screen.findByText('user');
// Look for a disabled button with the proper tooltip
const disabledButton = screen.getByText('Message').closest('button');
expect(disabledButton).toBeDisabled();
expect(disabledButton).toHaveAttribute('title', expect.stringContaining('Cannot message users from indirectly connected servers'));
}
});
test('should have bot description', async () => {
@ -205,20 +374,19 @@ describe('components/ProfilePopover', () => {
test('should match props passed into PopoverUserAttributes Pluggable component', async () => {
const [props, initialState] = getBasePropsAndState();
const mockPluginComponent = ({
hide,
status,
user,
}: {
hide: Props['hide'];
status?: string;
const mockPluginComponent: React.ComponentType<{
hide?: Props['hide'];
status: string | null;
user: UserProfile;
}) => {
fromWebhook?: boolean;
theme?: any;
webSocketClient?: any;
}> = ({hide, status, user}) => {
hide?.();
return (<span>{`${status} ${user.id}`}</span>);
};
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent as any}];
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent}];
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(props.hide).toHaveBeenCalled();
@ -227,20 +395,18 @@ describe('components/ProfilePopover', () => {
test('should match props passed into PopoverUserActions Pluggable component', async () => {
const [props, initialState] = getBasePropsAndState();
const mockPluginComponent = ({
hide,
status,
user,
}: {
hide: Props['hide'];
status?: string;
const mockPluginComponent: React.ComponentType<{
hide?: Props['hide'];
status: string | null;
user: UserProfile;
}) => {
theme?: any;
webSocketClient?: any;
}> = ({hide, status, user}) => {
hide?.();
return (<span>{`${status} ${user.id}`}</span>);
};
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent as any}];
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent}];
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(props.hide).toHaveBeenCalled();
@ -317,7 +483,15 @@ describe('components/ProfilePopover', () => {
test('should disable start call button when call is ongoing in the DM', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig?: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
const button = (await screen.findByLabelText('Call with user is ongoing')).closest('button');
@ -326,7 +500,15 @@ describe('components/ProfilePopover', () => {
test('should not show start call button when calls in channel have been explicitly disabled', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels: Record<string, {enabled: boolean}>;
callsConfig?: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
await act(async () => {
@ -337,7 +519,15 @@ describe('components/ProfilePopover', () => {
test('should not show start call button for users when calls test mode is on', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
await act(async () => {
@ -347,8 +537,24 @@ describe('components/ProfilePopover', () => {
test('should show start call button for users when calls test mode is on if calls in channel have been explicitly enabled', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
(initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
// Set channels
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels: Record<string, {enabled: boolean}>;
callsConfig?: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}};
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
await act(async () => {
@ -358,7 +564,15 @@ describe('components/ProfilePopover', () => {
test('should show start call button for admin when calls test mode is on', async () => {
const [props, initialState] = getBasePropsAndState();
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
// Type assertion needed for dynamic plugin state access
(initialState as DeepPartial<GlobalState> & {
'plugins-com.mattermost.calls': {
sessions?: Record<string, unknown>;
channels?: Record<string, {enabled: boolean}>;
callsConfig: {DefaultEnabled: boolean};
};
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
initialState.entities = {
...initialState.entities!,
users: {

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import {screen, waitFor} from '@testing-library/react';
import React from 'react';
import {canUserDirectMessage} from 'actions/user_actions';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
@ -11,6 +13,12 @@ import type {GlobalState} from 'types/store';
import ProfilePopoverOtherUserRow from './profile_popover_other_user_row';
jest.mock('actions/user_actions', () => ({
canUserDirectMessage: jest.fn(),
}));
const mockCanUserDirectMessage = canUserDirectMessage as jest.MockedFunction<typeof canUserDirectMessage>;
describe('components/ProfilePopoverOtherUserRow', () => {
const baseProps = {
user: TestHelper.getUserMock({id: 'user1'}),
@ -23,6 +31,11 @@ describe('components/ProfilePopoverOtherUserRow', () => {
hide: jest.fn(),
};
beforeEach(() => {
mockCanUserDirectMessage.mockClear();
mockCanUserDirectMessage.mockReturnValue(jest.fn().mockResolvedValue({data: {can_dm: true}}));
});
const initialState = {
entities: {
general: {
@ -44,7 +57,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
expect(screen.getByText('Message')).toBeInTheDocument();
});
test('should show message button for remote users when EnableSharedChannelsDMs is enabled', () => {
test('should show message button for remote users when EnableSharedChannelsDMs is enabled', async () => {
const remoteUser = {
...baseProps.user,
remote_id: 'remote1',
@ -72,7 +85,10 @@ describe('components/ProfilePopoverOtherUserRow', () => {
state,
);
expect(screen.getByText('Message')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Message')).toBeInTheDocument();
});
expect(mockCanUserDirectMessage).toHaveBeenCalledWith('currentUser', 'user1');
});
test('should hide message button for remote users when EnableSharedChannelsDMs is disabled', () => {
@ -104,6 +120,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
);
expect(screen.queryByText('Message')).not.toBeInTheDocument();
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
});
test('should show message button for local users when EnableSharedChannelsDMs is disabled', () => {
@ -129,5 +146,6 @@ describe('components/ProfilePopoverOtherUserRow', () => {
);
expect(screen.getByText('Message')).toBeInTheDocument();
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
});
});

View file

@ -1,19 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import React, {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import type {UserProfile} from '@mattermost/types/users';
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {canUserDirectMessage} from 'actions/user_actions';
import ProfilePopoverAddToChannel from 'components/profile_popover/profile_popover_add_to_channel';
import ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
import type {GlobalState} from 'types/store';
type Props = {
user: UserProfile;
fullname: string;
@ -35,33 +36,125 @@ const ProfilePopoverOtherUserRow = ({
hide,
fullname,
}: Props) => {
const isSharedChannelsDMsEnabled = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'EnableSharedChannelsDMs') === 'true');
const intl = useIntl();
const dispatch = useDispatch();
const [canMessage, setCanMessage] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
const isSharedChannelsDMsEnabled = useSelector((state: GlobalState) => {
return getFeatureFlagValue(state, 'EnableSharedChannelsDMs') === 'true';
});
// Check if this user can be messaged directly using server-side validation
useEffect(() => {
const checkCanMessage = async () => {
if (!user.remote_id) {
// Local users can always be messaged
setCanMessage(true);
return;
}
if (!isSharedChannelsDMsEnabled) {
// Feature disabled - don't allow remote user messaging
setCanMessage(false);
return;
}
setIsLoading(true);
try {
const result = await dispatch(canUserDirectMessage(currentUserId, user.id));
if (result.data) {
setCanMessage(result.data.can_dm);
} else {
setCanMessage(false);
}
} catch (error) {
// Error checking DM permissions
setCanMessage(false);
} finally {
setIsLoading(false);
}
};
checkCanMessage();
}, [dispatch, currentUserId, user.id, user.remote_id, isSharedChannelsDMsEnabled]);
if (user.id === currentUserId || haveOverrideProp) {
return null;
}
// Hide Message button for remote users when EnableSharedChannelsDMs feature flag is off
// For remote users, we need to check permissions; for local users, always show
const isRemoteUser = Boolean(user.remote_id);
const showMessageButton = isSharedChannelsDMsEnabled || !isRemoteUser;
const shouldShowButton = !isRemoteUser || (isSharedChannelsDMsEnabled && canMessage !== null);
return (
<div className='user-popover__bottom-row-container'>
{showMessageButton && (
<button
type='button'
className='btn btn-primary btn-sm'
onClick={handleShowDirectChannel}
>
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
{shouldShowButton && (
<>
{isLoading ? (
<button
type='button'
className='btn btn-primary btn-sm disabled'
disabled={true}
>
<i
className='icon icon-loading'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm.checking'
defaultMessage='Checking...'
/>
</button>
) : (
<>
{canMessage ? (
<button
type='button'
className='btn btn-primary btn-sm'
onClick={handleShowDirectChannel}
aria-label={intl.formatMessage({
id: 'user_profile.send.dm.aria_label',
defaultMessage: 'Send message to {user}',
}, {user: user.username})}
>
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
) : (
<button
type='button'
className='btn btn-primary btn-sm disabled'
disabled={true}
title={intl.formatMessage({
id: 'user_profile.send.dm.no_connection',
defaultMessage: 'Cannot message users from indirectly connected servers',
})}
aria-label={intl.formatMessage({
id: 'user_profile.send.dm.no_connection.aria_label',
defaultMessage: 'Cannot message {user}. Their server is not directly connected.',
}, {user: user.username})}
>
<i
className='icon icon-send'
aria-hidden='true'
/>
<FormattedMessage
id='user_profile.send.dm'
defaultMessage='Message'
/>
</button>
)}
</>
)}
</>
)}
<div className='user-popover__bottom-row-end'>
<ProfilePopoverAddToChannel

View file

@ -5916,6 +5916,10 @@
"user_profile.roleTitle.system_admin": "System Admin",
"user_profile.roleTitle.team_admin": "Team Admin",
"user_profile.send.dm": "Message",
"user_profile.send.dm.aria_label": "Send message to {user}",
"user_profile.send.dm.checking": "Checking...",
"user_profile.send.dm.no_connection": "Cannot message users from indirectly connected servers",
"user_profile.send.dm.no_connection.aria_label": "Cannot message {user}. Their server is not directly connected.",
"user_profile.send.dm.yourself": "Send yourself a message",
"user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.",
"user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs",

View file

@ -646,6 +646,19 @@ export function getUserByEmail(email: string) {
});
}
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
return async (dispatch, getState) => {
try {
const result = await Client4.canUserDirectMessage(userId, otherUserId);
return {data: result};
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
};
}
export function getStatusesByIds(userIds: Array<UserProfile['id']>): ActionFuncAsync<UserStatus[]> {
return async (dispatch, getState) => {
if (!userIds || userIds.length === 0) {

View file

@ -402,6 +402,10 @@ export default class Client4 {
return `${this.getBaseRoute()}/hooks/outgoing/${hookId}`;
}
getSharedChannelsRoute() {
return `${this.getBaseRoute()}/sharedchannels`;
}
getOAuthRoute() {
return `${this.url}/oauth`;
}
@ -960,6 +964,13 @@ export default class Client4 {
);
};
canUserDirectMessage = (userId: string, otherUserId: string) => {
return this.doFetch<{can_dm: boolean}>(
`${this.getSharedChannelsRoute()}/users/${userId}/can_dm/${otherUserId}`,
{method: 'get'},
);
};
getProfilePictureUrl = (userId: string, lastPictureUpdate: number) => {
const params: any = {};