...
 
Commits (20)
......@@ -207,8 +207,8 @@ export async function loadProfilesForGM() {
continue;
}
const userIds = userIdsInChannels[channel.id] || [];
if (userIds.length >= Constants.MIN_USERS_IN_GM) {
const userIds = userIdsInChannels[channel.id] || new Set();
if (userIds.size >= Constants.MIN_USERS_IN_GM) {
continue;
}
......
......@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {leaveChannel as leaveChannelRedux, joinChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels';
import {getChannel, getChannelByName} from 'mattermost-redux/selectors/entities/channels';
import {getChannel, getChannelByName, getCurrentChannel, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, getUserByUsername} from 'mattermost-redux/selectors/entities/users';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
......@@ -28,8 +28,14 @@ export function checkAndSetMobileView() {
export function goToLastViewedChannel() {
return async (dispatch, getState) => {
const state = getState();
const lastViewedChannel = getChannelByName(state, getLastViewedChannelName(state));
return dispatch(switchToChannel(lastViewedChannel));
const currentChannel = getCurrentChannel(state);
let channelToSwitchTo = getChannelByName(state, getLastViewedChannelName(state));
if (currentChannel.id === channelToSwitchTo.id) {
channelToSwitchTo = getDefaultChannel(state);
}
return dispatch(switchToChannel(channelToSwitchTo));
};
}
......
......@@ -4,6 +4,7 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {General} from 'mattermost-redux/constants';
import {leaveChannel} from 'mattermost-redux/actions/channels';
import {browserHistory} from 'utils/browser_history';
......@@ -30,8 +31,13 @@ jest.mock('mattermost-redux/actions/channels', () => ({
}),
}));
jest.mock('selectors/local_storage', () => ({
getLastViewedChannelName: () => 'channel1',
}));
describe('channel view actions', () => {
const channel1 = {id: 'channelid1', name: 'channel1', display_name: 'Channel 1', type: 'O'};
const channel1 = {id: 'channelid1', name: 'channel1', display_name: 'Channel 1', type: 'O', team_id: 'teamid1'};
const townsquare = {id: 'channelid2', name: General.DEFAULT_CHANNEL, display_name: 'Town Square', type: 'O', team_id: 'teamid1'};
const gmChannel = {id: 'gmchannelid', name: 'gmchannel', display_name: 'GM Channel 1', type: 'G'};
const team1 = {id: 'teamid1', name: 'team1'};
......@@ -47,7 +53,8 @@ describe('channel view actions', () => {
teams: {teamid1: team1},
},
channels: {
channels: {channelid1: channel1, gmchannelid: gmChannel},
currentChannelId: 'channelid1',
channels: {channelid1: channel1, channelid2: townsquare, gmchannelid: gmChannel},
myMembers: {gmchannelid: {channel_id: 'gmchannelid', user_id: 'userid1'}},
},
general: {
......@@ -90,4 +97,11 @@ describe('channel view actions', () => {
expect(leaveChannel).toHaveBeenCalledWith('channelid');
});
});
describe('goToLastViewedChannel', () => {
test('should switch to town square if last viewed channel is current channel', async () => {
await store.dispatch(Actions.goToLastViewedChannel());
expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${General.DEFAULT_CHANNEL}`);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/AddUserToChannelModal should match snapshot 1`] = `
<Modal
animation={true}
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="modal--overflow"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h4"
>
<FormattedMessage
defaultMessage="Add {name} to a channel"
id="add_user_to_channel_modal.title"
values={
Object {
"name": "Fake Person",
}
}
/>
</ModalTitle>
</ModalHeader>
<form
onSubmit={[Function]}
role="form"
>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="modal__hint"
>
<FormattedMessage
defaultMessage="Type to find a channel. Use ↑↓ to browse, ↵ to select, ESC to dismiss."
id="add_user_to_channel_modal.help"
values={Object {}}
/>
</div>
<SuggestionBox
className="form-control focused"
completeOnTab={false}
containerClass=""
delayInputUpdate={true}
isRHS={false}
listComponent={[Function]}
listStyle="bottom"
listenForMentionKeyClick={false}
maxLength="64"
onChange={[Function]}
onItemSelected={[Function]}
openOnFocus={false}
openWhenEmpty={true}
providers={
Array [
SearchChannelWithPermissionsProvider {
"disableDispatches": false,
"latestComplete": true,
"latestPrefix": "",
"requestStarted": false,
},
]
}
renderDividers={false}
renderNoResults={false}
replaceAllInputOnSelect={false}
requiredCharacters={1}
value=""
/>
<div>
<br />
</div>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
componentClass="div"
>
<button
className="btn btn-link"
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Cancel"
id="add_user_to_channel_modal.cancel"
values={Object {}}
/>
</button>
<button
className="btn btn-primary"
disabled={true}
id="add-user-to-channel-modal__add-button"
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Add"
id="add_user_to_channel_modal.add"
values={Object {}}
/>
</button>
</ModalFooter>
</form>
</Modal>
`;
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import {getFullName} from 'mattermost-redux/utils/user_utils';
import SearchChannelWithPermissionsProvider from 'components/suggestion/search_channel_with_permissions_provider.jsx';
import SuggestionBox from 'components/suggestion/suggestion_box.jsx';
import SuggestionList from 'components/suggestion/suggestion_list.jsx';
import {placeCaretAtEnd} from 'utils/utils.jsx';
export default class AddUserToChannelModal extends React.Component {
static propTypes = {
/**
* Function that's called when modal is closed
*/
onHide: PropTypes.func.isRequired,
/**
* The user that is being added to a channel
*/
user: PropTypes.object.isRequired,
/**
* Object used to determine if the user
* is a member of a given channel
*/
channelMembers: PropTypes.object.isRequired,
actions: PropTypes.shape({
/**
* Function to add the user to a channel
*/
addChannelMember: PropTypes.func.isRequired,
/**
* Function to fetch the user's channel membership
*/
getChannelMember: PropTypes.func.isRequired,
}).isRequired,
}
constructor(props) {
super(props);
this.state = {
/**
* Whether or not the modal is visible
*/
show: true,
/**
* Whether or not a request to add the user is in progress
*/
saving: false,
/**
* Whether or not a request to check for the user's channel membership
* is in progress
*/
checkingForMembership: false,
/**
* The user input in the channel search box
*/
text: '',
/**
* The id for the channel that is selected
*/
selectedChannelId: null,
/**
* An error to display when the add request fails
*/
submitError: '',
};
this.suggestionProviders = [new SearchChannelWithPermissionsProvider()];
this.enableChannelProvider();
}
enableChannelProvider = () => {
this.suggestionProviders[0].disableDispatches = false;
}
focusTextbox = () => {
if (this.channelSearchBox == null) {
return;
}
const textbox = this.channelSearchBox.getTextbox();
if (document.activeElement !== textbox) {
textbox.focus();
placeCaretAtEnd(textbox);
}
}
onInputChange = (e) => {
this.setState({text: e.target.value, selectedChannelId: null});
}
onHide = () => {
this.setState({show: false});
this.props.onHide();
}
setSearchBoxRef = (input) => {
this.channelSearchBox = input;
this.focusTextbox();
}
handleSubmitError = (error) => {
if (error) {
this.setState({submitError: error.message, saving: false});
}
}
didSelectChannel = (selection) => {
const channel = selection.channel;
const userId = this.props.user.id;
this.setState({
text: channel.display_name,
selectedChannelId: channel.id,
checkingForMembership: true,
submitError: '',
});
this.props.actions.getChannelMember(channel.id, userId).then(() => {
this.setState({checkingForMembership: false});
});
}
handleSubmit = (e) => {
if (e && e.preventDefault) {
e.preventDefault();
}
const channelId = this.state.selectedChannelId;
const user = this.props.user;
if (!channelId) {
return;
}
if (this.isUserMemberOfChannel(channelId) || this.state.saving) {
return;
}
this.setState({saving: true});
this.props.actions.addChannelMember(channelId, user.id).then(({error}) => {
if (error) {
this.handleSubmitError(error);
} else {
this.onHide();
}
});
}
isUserMemberOfChannel = (channelId) => {
const user = this.props.user;
const memberships = this.props.channelMembers;
if (!channelId) {
return false;
}
if (!memberships[channelId]) {
return false;
}
return Boolean(memberships[channelId][user.id]);
}
render() {
const user = this.props.user;
const channelId = this.state.selectedChannelId;
const targetUserIsMemberOfSelectedChannel = this.isUserMemberOfChannel(channelId);
let name = getFullName(user);
if (!name) {
name = `@${user.username}`;
}
let errorMsg;
if (!this.state.saving) {
if (this.state.submitError) {
errorMsg = (
<label
id='add-user-to-channel-modal__invite-error'
className='modal__error has-error control-label'
>
{this.state.submitError}
</label>
);
} else if (targetUserIsMemberOfSelectedChannel) {
errorMsg = (
<label
id='add-user-to-channel-modal__user-is-member'
className='modal__error has-error control-label'
>
<FormattedMessage
id='add_user_to_channel_modal.membershipExistsError'
defaultMessage='{name} is already a member of that channel'
values={{
name,
}}
/>
</label>
);
}
}
const help = (
<FormattedMessage
id='add_user_to_channel_modal.help'
defaultMessage='Type to find a channel. Use ↑↓ to browse, ↵ to select, ESC to dismiss.'
/>
);
const content = (
<SuggestionBox
ref={this.setSearchBoxRef}
className='form-control focused'
onChange={this.onInputChange}
value={this.state.text}
onKeyDown={this.handleKeyDown}
onItemSelected={this.didSelectChannel}
listComponent={SuggestionList}
maxLength='64'
providers={this.suggestionProviders}
listStyle='bottom'
completeOnTab={false}
renderDividers={false}
delayInputUpdate={true}
openWhenEmpty={true}
/>
);
const shouldDisableAddButton = targetUserIsMemberOfSelectedChannel ||
this.state.checkingForMembership ||
Boolean(!this.state.selectedChannelId) ||
this.state.saving;
return (
<Modal
dialogClassName='modal--overflow'
show={this.state.show}
onHide={this.onHide}
onExited={this.props.onHide}
ref='modal'
enforceFocus={true}
>
<Modal.Header closeButton={true}>
<Modal.Title>
<FormattedMessage
id='add_user_to_channel_modal.title'
defaultMessage='Add {name} to a channel'
values={{
name,
}}
/>
</Modal.Title>
</Modal.Header>
<form
role='form'
onSubmit={this.handleSubmit}
>
<Modal.Body>
<div className='modal__hint'>
{help}
</div>
{content}
<div>
{errorMsg}
<br/>
</div>
</Modal.Body>
<Modal.Footer>
<button
type='button'
className='btn btn-link'
onClick={this.onHide}
>
<FormattedMessage
id='add_user_to_channel_modal.cancel'
defaultMessage='Cancel'
/>
</button>
<button
type='button'
id='add-user-to-channel-modal__add-button'
className='btn btn-primary'
onClick={this.handleSubmit}
disabled={shouldDisableAddButton}
>
<FormattedMessage
id='add_user_to_channel_modal.add'
defaultMessage='Add'
/>
</button>
</Modal.Footer>
</form>
</Modal>
);
}
}
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AddUserToChannelModal from 'components/add_user_to_channel_modal/add_user_to_channel_modal';
describe('components/AddUserToChannelModal', () => {
const baseProps = {
channelMembers: {},
user: {
id: 'someUserId',
first_name: 'Fake',
last_name: 'Person',
},
onHide: jest.fn(),
actions: {
addChannelMember: jest.fn().mockResolvedValue({}),
getChannelMember: jest.fn().mockResolvedValue({}),
},
};
it('should match snapshot', () => {
const wrapper = shallow(
<AddUserToChannelModal {...baseProps}/>
);
expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true);
expect(wrapper.find('#add-user-to-channel-modal__user-is-member').exists()).toBe(false);
expect(wrapper.find('#add-user-to-channel-modal__invite-error').exists()).toBe(false);
expect(wrapper).toMatchSnapshot();
});
it('should enable the add button when a channel is selected', () => {
const wrapper = shallow(
<AddUserToChannelModal {...baseProps}/>
);
wrapper.setState({selectedChannelId: 'someChannelId'});
expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(false);
expect(wrapper.find('#add-user-to-channel-modal__invite-error').exists()).toBe(false);
});
it('should show invite error when an error message is captured', () => {
const wrapper = shallow(
<AddUserToChannelModal {...baseProps}/>
);
wrapper.setState({submitError: 'some error'});
expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true);
expect(wrapper.find('#add-user-to-channel-modal__invite-error').exists()).toBe(true);
});
it('should disable add button when membership is being checked', () => {
const wrapper = shallow(
<AddUserToChannelModal {...baseProps}/>
);
wrapper.setState({
selectedChannelId: 'someChannelId',
checkingForMembership: true,
});
expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true);
});
it('should display error message if user is a member of the selected channel', () => {
const props = {...baseProps,
channelMembers: {
someChannelId: {
someUserId: {},
},
},
};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
wrapper.setState({selectedChannelId: 'someChannelId'});
expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true);
expect(wrapper.find('#add-user-to-channel-modal__user-is-member').exists()).toBe(true);
});
it('should disable the add button when saving', () => {
const wrapper = shallow(
<AddUserToChannelModal {...baseProps}/>
);
wrapper.setState({
selectedChannelId: 'someChannelId',
saving: true,
});
expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true);
});
describe('didSelectChannel', () => {
it('should fetch the selected user\'s membership for the selected channel', () => {
const props = {...baseProps};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
const selection = {channel: {id: 'someChannelId', display_name: 'channelName'}};
wrapper.instance().didSelectChannel(selection);
expect(props.actions.getChannelMember).toBeCalledWith('someChannelId', 'someUserId');
});
it('should match state on selection', async () => {
const promise = Promise.resolve({});
const props = {
...baseProps,
actions: {
...baseProps.actions,
getChannelMember: jest.fn(() => {
return promise;
}),
},
};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
expect(wrapper.state().text).toEqual('');
expect(wrapper.state().checkingForMembership).toEqual(false);
expect(wrapper.state().selectedChannelId).toEqual(null);
expect(wrapper.state().submitError).toEqual('');
const selection = {channel: {id: 'someChannelId', display_name: 'channelName'}};
wrapper.setState({submitError: 'some pre-existing error'});
wrapper.instance().didSelectChannel(selection);
expect(wrapper.state().text).toEqual('channelName');
expect(wrapper.state().checkingForMembership).toEqual(true);
expect(wrapper.state().selectedChannelId).toEqual('someChannelId');
expect(wrapper.state().submitError).toEqual('');
await promise;
expect(wrapper.state().checkingForMembership).toEqual(false);
});
});
describe('handleSubmit', () => {
it('should do nothing if no channel is selected', () => {
const props = {...baseProps};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
wrapper.instance().handleSubmit();
expect(wrapper.state().saving).toBe(false);
expect(props.actions.addChannelMember).not.toBeCalled();
});
it('should do nothing if user is a member of the selected channel', () => {
const props = {...baseProps,
channelMembers: {
someChannelId: {
someUserId: {},
},
},
};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
wrapper.setState({selectedChannelId: 'someChannelId'});
wrapper.instance().handleSubmit();
expect(wrapper.state().saving).toBe(false);
expect(props.actions.addChannelMember).not.toBeCalled();
});
it('should submit if user is not a member of the selected channel', () => {
const props = {...baseProps,
channelMembers: {
someChannelId: {},
},
};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
wrapper.setState({selectedChannelId: 'someChannelId'});
wrapper.instance().handleSubmit();
expect(wrapper.state().saving).toBe(true);
expect(props.actions.addChannelMember).toBeCalled();
});
test('should match state when save is successful', async () => {
const onHide = jest.fn();
const promise = Promise.resolve({});
const props = {
...baseProps,
onHide,
actions: {
...baseProps.actions,
addChannelMember: () => promise,
},
};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
expect(wrapper.state().show).toBe(true);
expect(wrapper.state().saving).toBe(false);
wrapper.setState({selectedChannelId: 'someChannelId'});
wrapper.instance().handleSubmit();
expect(wrapper.state().show).toBe(true);
expect(wrapper.state().saving).toBe(true);
await promise;
expect(wrapper.state().submitError).toEqual('');
expect(wrapper.state().show).toBe(false);
expect(onHide).toHaveBeenCalled();
});
test('should match state when save fails', async () => {
const onHide = jest.fn();
const promise = Promise.resolve({error: new Error('some error')});
const props = {
...baseProps,
onHide,
actions: {
...baseProps.actions,
addChannelMember: () => promise,
},
};
const wrapper = shallow(
<AddUserToChannelModal {...props}/>
);
expect(wrapper.state().show).toBe(true);
wrapper.setState({selectedChannelId: 'someChannelId'});
wrapper.instance().handleSubmit();
await promise;
expect(wrapper.state().submitError).toEqual('some error');
expect(wrapper.state().show).toBe(true);
expect(onHide).not.toHaveBeenCalled();
});
});
});
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {addChannelMember, getChannelMember} from 'mattermost-redux/actions/channels';
import {getChannelMembersInChannels} from 'mattermost-redux/selectors/entities/channels';
import AddUserToChannelModal from './add_user_to_channel_modal.jsx';
function mapStateToProps(state) {
const channelMembers = getChannelMembersInChannels(state) || {};
return {
channelMembers,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addChannelMember,
getChannelMember,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AddUserToChannelModal);
......@@ -259,6 +259,14 @@ export default class AdminConsole extends React.Component {
schema: AdminDefinition.settings.authentication.gitlab.schema,
}}
/>
<SCRoute
path={`${props.match.url}/phabricator`}
component={SchemaAdminSettings}
extraProps={{
...extraProps,
schema: AdminDefinition.settings.authentication.phabricator.schema,
}}
/>
<SCRoute
path={`${props.match.url}/oauth`}
component={SchemaAdminSettings}
......
......@@ -498,6 +498,11 @@ export default {
display_name: t('admin.team.showUsername'),
display_name_default: 'Show username (default)',
},
{
value: Constants.TEAMMATE_NAME_DISPLAY.SHOW_NICKNAME_USERNAME,
display_name: t('admin.team.showNicknameOrUsername'),
display_name_default: 'Show nickname if one exists, otherwise show username',
},
{
value: Constants.TEAMMATE_NAME_DISPLAY.SHOW_NICKNAME_FULLNAME,
display_name: t('admin.team.showNickname'),
......@@ -835,6 +840,106 @@ export default {
],
},
},
phabricator: {
schema: {
id: 'PhabricatorSettings',
name: t('admin.authentication.phabricator'),
name_default: 'Phabricator',
onConfigLoad: (config) => {
const newState = {};
newState['PhabricatorSettings.Url'] = config.PhabricatorSettings.UserApiEndpoint.replace('/api/v4/user', '');
return newState;
},
onConfigSave: (config) => {
const newConfig = {...config};
newConfig.PhabricatorSettings.UserApiEndpoint = config.PhabricatorSettings.Url.replace(/\/$/, '') + '/api/user.whoami';
return newConfig;
},
settings: [
{
type: Constants.SettingsTypes.TYPE_BOOL,
key: 'PhabricatorSettings.Enable',
label: t('admin.phabricator.enableTitle'),
label_default: 'Enable authentication with Phabricator: ',
help_text: t('admin.phabricator.enableDescription'),
help_text_default: 'When true, Mattermost allows team creation and account signup using Phabricator OAuth.\n \n1. Log in to your Phabricator account and go to Applications → OAuth Server → Create OAuth Server.\n2. Enter Redirect URIs "<your-mattermost-url>/login/phabricator/complete" (example: http://localhost:8065/login/phabricator/complete) and "<your-mattermost-url>/signup/phabricator/complete".\n3. Then use "Show Application Secret" and "Client PHID" fields from Phabricator to complete the options below.\n4. Complete the Endpoint URLs below.',
help_text_markdown: true,
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'PhabricatorSettings.Id',
label: t('admin.phabricator.clientIdTitle'),
label_default: 'Application ID:',
help_text: t('admin.phabricator.clientIdDescription'),
help_text_default: 'Obtain this value via the instructions above for logging into Phabricator.',
placeholder: t('admin.phabricator.clientIdExample'),
placeholder_default: 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"',
isDisabled: needsUtils.stateValueFalse('PhabricatorSettings.Enable'),
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'PhabricatorSettings.Secret',
label: t('admin.phabricator.clientSecretTitle'),
label_default: 'Application Secret Key:',
help_text: t('admin.phabricator.clientSecretDescription'),
help_text_default: 'Obtain this value via the instructions above for logging into Phabricator.',
placeholder: t('admin.phabricator.clientSecretExample'),
placeholder_default: 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"',
isDisabled: needsUtils.stateValueFalse('PhabricatorSettings.Enable'),
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'PhabricatorSettings.Url',
label: t('admin.phabricator.siteUrl'),
label_default: 'Phabricator Site URL:',
help_text: t('admin.phabricator.siteUrlDescription'),
help_text_default: 'Enter the URL of your Phabricator instance, e.g. https://phabricator.example.com/. If your Phabricator instance is not set up with SSL, start the URL with http:// instead of https://.',
placeholder: t('admin.phabricator.siteUrlExample'),
placeholder_default: 'E.g.: https://',
isDisabled: needsUtils.stateValueFalse('PhabricatorSettings.Enable'),
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'PhabricatorSettings.UserApiEndpoint',
label: t('admin.phabricator.userTitle'),
label_default: 'User API Endpoint:',
dynamic_value: (value, config, state) => {
if (state['PhabricatorSettings.Url']) {
return state['PhabricatorSettings.Url'].replace(/\/$/, '') + '/api/user.whoami';
}
return '';
},
isDisabled: () => true,
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'PhabricatorSettings.AuthEndpoint',
label: t('admin.phabricator.authTitle'),
label_default: 'Auth Endpoint:',
dynamic_value: (value, config, state) => {
if (state['PhabricatorSettings.Url']) {
return state['PhabricatorSettings.Url'].replace(/\/$/, '') + '/oauthserver/auth/';
}
return '';
},
isDisabled: () => true,
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'PhabricatorSettings.TokenEndpoint',
label: t('admin.phabricator.tokenTitle'),
label_default: 'Token Endpoint:',
dynamic_value: (value, config, state) => {
if (state['PhabricatorSettings.Url']) {
return state['PhabricatorSettings.Url'].replace(/\/$/, '') + '/oauthserver/token/';
}
return '';
},
isDisabled: () => true,
},
],
},
},
oauth: {
schema: {
id: 'OAuthSettings',
......@@ -1107,7 +1212,7 @@ export default {
label: t('admin.email.allowSignupTitle'),
label_default: 'Enable account creation with email:',
help_text: t('admin.email.allowSignupDescription'),
help_text_default: 'When true, Mattermost allows account creation using email and password. This value should be false only when you want to limit sign up to a single sign-on service like AD/LDAP, SAML or GitLab.',
help_text_default: 'When true, Mattermost allows account creation using email and password. This value should be false only when you want to limit sign up to a single sign-on service like AD/LDAP, SAML, GitLab or Phabricator.',
},
{
type: Constants.SettingsTypes.TYPE_BOOL,
......@@ -1911,7 +2016,7 @@ export default {
label: t('admin.service.ssoSessionDays'),
label_default: 'Session Length SSO (days):',
help_text: t('admin.service.ssoSessionDaysDesc'),
help_text_default: 'The number of days from the last time a user entered their credentials to the expiry of the users session. If the authentication method is SAML or GitLab, the user may automatically be logged back in to Mattermost if they are already logged in to SAML or GitLab. After changing this setting, the setting will take effect after the next time the user enters their credentials.',
help_text_default: 'The number of days from the last time a user entered their credentials to the expiry of the users session. If the authentication method is SAML, GitLab or Phabricator, the user may automatically be logged back in to Mattermost if they are already logged in to SAML, GitLab or Phabricator. After changing this setting, the setting will take effect after the next time the user enters their credentials.',
placeholder: t('admin.service.sessionDaysEx'),
placeholder_default: 'E.g.: "30"',
},
......
......@@ -72,6 +72,8 @@ export default class AdminSidebar extends React.Component {
let oauthSettings = null;
let ldapSettings = null;
let samlSettings = null;
let gitlabSettings = null;
let phabricatorSettings = null;
let clusterSettings = null;
let metricsSettings = null;
let complianceSettings = null;
......@@ -176,7 +178,7 @@ export default class AdminSidebar extends React.Component {
title={
<FormattedMessage
id='admin.sidebar.customTermsOfService'
defaultMessage='Custom Terms of Service'
defaultMessage='Custom Terms of Service (Beta)'
/>
}
/>
......@@ -234,7 +236,7 @@ export default class AdminSidebar extends React.Component {
/>
);
} else {
oauthSettings = (
gitlabSettings = (
<AdminSidebarSection
name='gitlab'
title={
......@@ -245,6 +247,18 @@ export default class AdminSidebar extends React.Component {
}
/>
);
phabricatorSettings = (
<AdminSidebarSection
name='phabricator'
title={
<FormattedMessage
id='admin.sidebar.phabricator'
defaultMessage='Phabricator'
/>
}
/>
);
}
if (this.props.license.IsLicensed === 'true') {
......@@ -534,6 +548,8 @@ export default class AdminSidebar extends React.Component {
}
/>
{oauthSettings}
{gitlabSettings}
{phabricatorSettings}
{ldapSettings}
{samlSettings}
{mfaSettings}
......
......@@ -6,7 +6,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap
>
<AdminHeader>
<FormattedMessage
defaultMessage="Custom Terms of Service"
defaultMessage="Custom Terms of Service (Beta)"
id="admin.support.termsOfServiceTitle"
values={Object {}}
/>
......@@ -66,7 +66,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap
>
<AdminHeader>
<FormattedMessage
defaultMessage="Custom Terms of Service"
defaultMessage="Custom Terms of Service (Beta)"
id="admin.support.termsOfServiceTitle"
values={Object {}}
/>
......@@ -126,7 +126,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap
>
<AdminHeader>
<FormattedMessage
defaultMessage="Custom Terms of Service"
defaultMessage="Custom Terms of Service (Beta)"
id="admin.support.termsOfServiceTitle"
values={Object {}}
/>
......@@ -186,7 +186,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap
>
<AdminHeader>
<FormattedMessage
defaultMessage="Custom Terms of Service"
defaultMessage="Custom Terms of Service (Beta)"
id="admin.support.termsOfServiceTitle"
values={Object {}}
/>
......
......@@ -150,7 +150,7 @@ export default class CustomTermsOfServiceSettings extends AdminSettings {
return (
<FormattedMessage
id='admin.support.termsOfServiceTitle'
defaultMessage='Custom Terms of Service'
defaultMessage='Custom Terms of Service (Beta)'
/>
);
}
......
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedHTMLMessage, FormattedMessage} from 'react-intl';
import * as Utils from 'utils/utils.jsx';
import AdminSettings from './admin_settings.jsx';
import BooleanSetting from './boolean_setting.jsx';
import SettingsGroup from './settings_group.jsx';
import TextSetting from './text_setting.jsx';
export default class PhabricatorSettings extends AdminSettings {
constructor(props) {
super(props);
this.getConfigFromState = this.getConfigFromState.bind(this);
this.renderSettings = this.renderSettings.bind(this);
this.updatePhabricatorUrl = this.updatePhabricatorUrl.bind(this);
}
getConfigFromState(config) {
config.PhabricatorSettings.Enable = this.state.enable;
config.PhabricatorSettings.Id = this.state.id;
config.PhabricatorSettings.Secret = this.state.secret;
config.PhabricatorSettings.UserApiEndpoint = this.state.userApiEndpoint;
config.PhabricatorSettings.AuthEndpoint = this.state.authEndpoint;
config.PhabricatorSettings.TokenEndpoint = this.state.tokenEndpoint;
return config;
}
getStateFromConfig(config) {
return {
enable: config.PhabricatorSettings.Enable,
id: config.PhabricatorSettings.Id,
secret: config.PhabricatorSettings.Secret,
phabricatorUrl: config.PhabricatorSettings.UserApiEndpoint.replace('/api/user.whoami', ''),
userApiEndpoint: config.PhabricatorSettings.UserApiEndpoint,
authEndpoint: config.PhabricatorSettings.AuthEndpoint,
tokenEndpoint: config.PhabricatorSettings.TokenEndpoint,
};
}
updatePhabricatorUrl(id, value) {
let trimmedValue = value;
if (value.endsWith('/')) {
trimmedValue = value.slice(0, -1);
}
this.setState({
saveNeeded: true,
phabricatorUrl: value,
userApiEndpoint: trimmedValue + '/api/user.whoami',
authEndpoint: trimmedValue + '/oauthserver/auth/',
tokenEndpoint: trimmedValue + '/oauthserver/token/',
});
}
isPhabricatorURLSetByEnv = () => {
// Assume that if one of these has been set using an environment variable,
// all of them have been set that way
return this.isSetByEnv('PhabricatorSettings.AuthEndpoint') ||
this.isSetByEnv('PhabricatorSettings.TokenEndpoint') ||
this.isSetByEnv('PhabricatorSettings.UserApiEndpoint');
};
renderTitle() {
return (
<FormattedMessage
id='admin.authentication.phabricator'
defaultMessage='Phabricator'
/>
);
}
renderSettings() {
return (
<SettingsGroup>
<BooleanSetting
id='enable'
label={
<FormattedMessage
id='admin.phabricator.enableTitle'
defaultMessage='Enable authentication with Phabricator: '
/>
}
helpText={
<div>
<FormattedMessage
id='admin.phabricator.enableDescription'
defaultMessage='When true, Mattermost allows team creation and account signup using Phabricator OAuth.'
/>
<br/>
<FormattedHTMLMessage
id='admin.phabricator.EnableHtmlDesc'
defaultMessage='<ol><li>Log in to your Phabricator account and go to Applications -> OAuth Server -> Create OAuth Server.</li><li>Enter Redirect URIs "<your-mattermost-url>/login/phabricator/complete" (example: http://localhost:8065/login/phabricator/complete) and "<your-mattermost-url>/signup/phabricator/complete". </li><li>Then use "Show Application Secret" and "Client PHID" fields from Phabricator to complete the options below.</li><li>Complete the Endpoint URLs below. </li></ol>'
/>
</div>
}
value={this.state.enable}
onChange={this.handleChange}
setByEnv={this.isSetByEnv('PhabricatorSettings.Enable')}
/>
<TextSetting
id='id'
label={
<FormattedMessage
id='admin.phabricator.clientIdTitle'
defaultMessage='Application ID:'
/>
}
placeholder={Utils.localizeMessage('admin.phabricator.clientIdExample', 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"')}
helpText={
<FormattedMessage
id='admin.phabricator.clientIdDescription'
defaultMessage='Obtain this value via the instructions above for logging into Phabricator'
/>
}
value={this.state.id}
onChange={this.handleChange}
disabled={!this.state.enable}
setByEnv={this.isSetByEnv('PhabricatorSettings.Id')}
/>
<TextSetting
id='secret'
label={
<FormattedMessage
id='admin.phabricator.clientSecretTitle'
defaultMessage='Application Secret Key:'
/>
}
placeholder={Utils.localizeMessage('admin.phabricator.clientSecretExample', 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"')}
helpText={
<FormattedMessage
id='admin.phabricator.clientSecretDescription'
defaultMessage='Obtain this value via the instructions above for logging into Phabricator.'
/>
}
value={this.state.secret}
onChange={this.handleChange}
disabled={!this.state.enable}
setByEnv={this.isSetByEnv('PhabricatorSettings.Secret')}
/>
<TextSetting
id='phabricatorUrl'
label={
<FormattedMessage
id='admin.phabricator.siteUrl'
defaultMessage='Phabricator Site URL:'
/>
}
placeholder={Utils.localizeMessage('admin.phabricator.siteUrlExample', 'E.g.: https://')}
helpText={
<FormattedMessage
id='admin.phabricator.siteUrlDescription'
defaultMessage='Enter the URL of your Phabricator instance, e.g. https://example.com:3000. If your Phabricator instance is not set up with SSL, start the URL with http:// instead of https://.'
/>
}
value={this.state.phabricatorUrl}
onChange={this.updatePhabricatorUrl}
disabled={!this.state.enable}
setByEnv={this.isPhabricatorURLSetByEnv()}
/>
<TextSetting
id='userApiEndpoint'
label={
<FormattedMessage
id='admin.phabricator.userTitle'
defaultMessage='User API Endpoint:'
/>
}
placeholder={''}
value={this.state.userApiEndpoint}
disabled={true}
setByEnv={false}
/>
<TextSetting
id='authEndpoint'
label={
<FormattedMessage
id='admin.phabricator.authTitle'
defaultMessage='Auth Endpoint:'
/>
}
placeholder={''}
value={this.state.authEndpoint}
disabled={true}
setByEnv={false}
/>
<TextSetting
id='tokenEndpoint'
label={
<FormattedMessage
id='admin.phabricator.tokenTitle'
defaultMessage='Token Endpoint:'
/>
}
placeholder={''}
value={this.state.tokenEndpoint}
disabled={true}
setByEnv={false}
/>
</SettingsGroup>
);
}
}
......@@ -28,6 +28,10 @@ export default class AnnouncementBar extends React.PureComponent {
this.setBodyClass(!this.props.showCloseButton);
}
componentWillUnmount() {
document.body.classList.remove('announcement-bar--fixed');
}
componentDidUpdate(prevProps) {
if (this.props.showCloseButton !== prevProps.showCloseButton) {
this.setBodyClass(!this.props.showCloseButton);
......
......@@ -4,7 +4,7 @@
import {joinChannel, getChannelByNameAndTeamName, markGroupChannelOpen} from 'mattermost-redux/actions/channels';
import {getUser, getUserByUsername, getUserByEmail} from 'mattermost-redux/actions/users';
import {getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, getUserByUsername as selectUserByUsername, getUser as selectUser} from 'mattermost-redux/selectors/entities/users';
import {getCurrentUserId, getUserByUsername as selectUserByUsername, getUser as selectUser, getUserByEmail as selectUserByEmail} from 'mattermost-redux/selectors/entities/users';
import {getChannelByName, getOtherChannels, getChannel, getChannelsNameMapInTeam} from 'mattermost-redux/selectors/entities/channels';
import {Constants} from 'utils/constants.jsx';
......@@ -59,15 +59,16 @@ export function onChannelByIdentifierEnter({match, history}) {
};
}
function goToChannelByChannelId(match, history) {
export function goToChannelByChannelId(match, history) {
return async (dispatch, getState) => {
const state = getState();
const {team, identifier} = match.params;
const channelId = identifier.toLowerCase();
let channel = getChannel(state, channelId);
const member = state.entities.channels.myMembers[channelId];
const teamObj = getTeamByName(state, team);
if (!channel) {
if (!channel || !member) {
const {data, error} = await dispatch(joinChannel(getCurrentUserId(state), teamObj.id, channelId, null));
if (error) {
handleChannelJoinError(match, history);
......@@ -98,8 +99,12 @@ export function goToChannelByChannelName(match, history) {
}
let channel = getChannelsNameMapInTeam(state, teamObj.id)[channelName];
let member;
if (channel) {
member = state.entities.channels.myMembers[channel.id];
}
if (!channel) {
if (!channel || !member) {
const {data, error: joinError} = await dispatch(joinChannel(getCurrentUserId(state), teamObj.id, null, channelName));
if (joinError) {
const {data: data2, error: getChannelError} = await dispatch(getChannelByNameAndTeamName(team, channelName, true));
......@@ -188,13 +193,13 @@ export function goToDirectChannelByUserIds(match, history) {
};
}
function goToDirectChannelByEmail(match, history) {
export function goToDirectChannelByEmail(match, history) {
return async (dispatch, getState) => {
const state = getState();
const {team, identifier} = match.params;
const email = identifier.toLowerCase();
let user = getUserByEmail(state, email);
let user = selectUserByEmail(state, email);
if (!user) {
const {data, error} = await dispatch(getUserByEmail(email));
if (error) {
......
......@@ -4,24 +4,42 @@
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import {joinChannel} from 'mattermost-redux/actions/channels';
import {getUserByEmail} from 'mattermost-redux/actions/users';
import {emitChannelClickEvent} from 'actions/global_actions.jsx';
import {goToChannelByChannelName, goToDirectChannelByUserId, goToDirectChannelByUserIds} from 'components/channel_layout/channel_identifier_router/actions';
import {
goToChannelByChannelName,
goToDirectChannelByUserId,
goToDirectChannelByUserIds,
goToChannelByChannelId,
goToDirectChannelByEmail,
} from 'components/channel_layout/channel_identifier_router/actions';
jest.mock('actions/global_actions.jsx', () => ({
emitChannelClickEvent: jest.fn(),
}));
jest.mock('mattermost-redux/actions/channels', () => ({
joinChannel: jest.fn(() => ({type: '', data: {channel: {id: 'channel_id3', name: 'achannel3', team_id: 'team_id1', type: 'O'}}})),
}));
jest.mock('mattermost-redux/actions/users', () => ({
getUserByEmail: jest.fn(() => ({type: '', data: {id: 'user_id3', email: 'user3@bladekick.com', username: 'user3'}})),
}));
const mockStore = configureStore([thunk]);
describe('Actions', () => {
const channel1 = {id: 'channel_id1', name: 'achannel', team_id: 'team_id1'};
const channel2 = {id: 'channel_id2', name: 'achannel', team_id: 'team_id2'};
const channel3 = {id: 'channel_id3', name: 'achannel3', team_id: 'team_id1', type: 'O'};
const initialState = {
entities: {
channels: {
currentChannelId: 'channel_id1',
channels: {channel_id1: channel1, channel_id2: channel2},
channels: {channel_id1: channel1, channel_id2: channel2, channel_id3: channel3},
myMembers: {channel_id1: {channel_id: 'channel_id1', user_id: 'current_user_id'}, channel_id2: {channel_id: 'channel_id2', user_id: 'current_user_id'}},
channelsInTeam: {team_id1: ['channel_id1'], team_id2: ['channel_id2']},
},
......@@ -40,12 +58,24 @@ describe('Actions', () => {
},
users: {
currentUserId: 'current_user_id',
profiles: {user_id2: {id: 'user_id2', username: 'user2'}},
profiles: {user_id2: {id: 'user_id2', username: 'user2', email: 'user2@bladekick.com'}},
},
general: {license: {IsLicensed: 'false'}},
general: {license: {IsLicensed: 'false'}, config: {}},
preferences: {myPreferences: {}},
},
};
describe('goToChannelByChannelId', () => {
test('switch to public channel we have locally but need to join', async () => {
const testStore = await mockStore(initialState);
const history = {replace: jest.fn()};
await testStore.dispatch(goToChannelByChannelId({params: {team: 'team1', identifier: 'channel_id3'}}, history));
expect(joinChannel).toHaveBeenCalledWith('current_user_id', 'team_id1', 'channel_id3', null);
expect(history.replace).toHaveBeenCalledWith('/team1/channels/achannel3');
});
});
describe('goToChannelByChannelName', () => {
test('switch to channel on different team with same name', async () => {
const testStore = await mockStore(initialState);
......@@ -53,6 +83,14 @@ describe('Actions', () => {
await testStore.dispatch(goToChannelByChannelName({params: {team: 'team2', identifier: 'achannel'}}, {}));
expect(emitChannelClickEvent).toHaveBeenCalledWith(channel2);
});
test('switch to public channel we have locally but need to join', async () => {
const testStore = await mockStore(initialState);
await testStore.dispatch(goToChannelByChannelName({params: {team: 'team1', identifier: 'achannel3'}}, {}));
expect(joinChannel).toHaveBeenCalledWith('current_user_id', 'team_id1', null, 'achannel3');
expect(emitChannelClickEvent).toHaveBeenCalledWith(channel3);
});
});
describe('goToDirectChannelByUserId', () => {
......@@ -90,4 +128,24 @@ describe('Actions', () => {
expect(history.replace).toHaveBeenCalledWith('/team2/messages/@user2');
});
});
describe('goToDirectChannelByEmail', () => {
test('switch to a direct channel by email with user already existing locally', async () => {
const testStore = await mockStore(initialState);
const history = {replace: jest.fn()};
await testStore.dispatch(goToDirectChannelByEmail({params: {team: 'team1', identifier: 'user2@bladekick.com'}}, history));
expect(getUserByEmail).not.toHaveBeenCalled();
expect(history.replace).toHaveBeenCalledWith('/team1/messages/@user2');
});
test('switch to a direct channel by email with user not existing locally', async () => {
const testStore = await mockStore(initialState);
const history = {replace: jest.fn()};
await testStore.dispatch(goToDirectChannelByEmail({params: {team: 'team1', identifier: 'user3@bladekick.com'}}, history));
expect(getUserByEmail).toHaveBeenCalledWith('user3@bladekick.com');
expect(history.replace).toHaveBeenCalledWith('/team1/messages/@user3');
});
});
});