mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
Merge pull request #2656 from mattermost/plt-2254
PLT-2254 Adding TLS options to LDAP
This commit is contained in:
@@ -132,6 +132,7 @@
|
||||
"Enable": false,
|
||||
"LdapServer": "",
|
||||
"LdapPort": 389,
|
||||
"ConnectionSecurity": "",
|
||||
"BaseDN": "",
|
||||
"BindUsername": "",
|
||||
"BindPassword": "",
|
||||
@@ -141,6 +142,7 @@
|
||||
"EmailAttribute": "",
|
||||
"UsernameAttribute": "",
|
||||
"IdAttribute": "",
|
||||
"SkipCertificateVerification": false,
|
||||
"QueryTimeout": 60
|
||||
},
|
||||
"ComplianceSettings": {
|
||||
@@ -148,4 +150,4 @@
|
||||
"Directory": "./data/",
|
||||
"EnableDaily": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2243,6 +2243,10 @@
|
||||
"id": "model.config.is_valid.sql_max_conn.app_error",
|
||||
"translation": "Invalid maximum open connection for SQL settings. Must be a positive number."
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.ldap_security.app_error",
|
||||
"translation": "Invalid connection security for LDAP settings. Must be '', 'TLS', or 'STARTTLS'"
|
||||
},
|
||||
{
|
||||
"id": "model.file_info.get.gif.app_error",
|
||||
"translation": "Could not decode gif."
|
||||
@@ -3827,4 +3831,4 @@
|
||||
"id": "web.watcher_fail.error",
|
||||
"translation": "Failed to add directory to watcher %v"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -162,12 +162,13 @@ type TeamSettings struct {
|
||||
|
||||
type LdapSettings struct {
|
||||
// Basic
|
||||
Enable *bool
|
||||
LdapServer *string
|
||||
LdapPort *int
|
||||
BaseDN *string
|
||||
BindUsername *string
|
||||
BindPassword *string
|
||||
Enable *bool
|
||||
LdapServer *string
|
||||
LdapPort *int
|
||||
ConnectionSecurity *string
|
||||
BaseDN *string
|
||||
BindUsername *string
|
||||
BindPassword *string
|
||||
|
||||
// Filtering
|
||||
UserFilter *string
|
||||
@@ -180,7 +181,8 @@ type LdapSettings struct {
|
||||
IdAttribute *string
|
||||
|
||||
// Advanced
|
||||
QueryTimeout *int
|
||||
SkipCertificateVerification *bool
|
||||
QueryTimeout *int
|
||||
}
|
||||
|
||||
type ComplianceSettings struct {
|
||||
@@ -526,6 +528,10 @@ func (o *Config) IsValid() *AppError {
|
||||
return NewLocAppError("Config.IsValid", "model.config.is_valid.rate_sec.app_error", nil, "")
|
||||
}
|
||||
|
||||
if !(*o.LdapSettings.ConnectionSecurity == CONN_SECURITY_NONE || *o.LdapSettings.ConnectionSecurity == CONN_SECURITY_TLS || *o.LdapSettings.ConnectionSecurity == CONN_SECURITY_STARTTLS) {
|
||||
return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_security.app_error", nil, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
62
webapp/components/admin_console/boolean_setting.jsx
Normal file
62
webapp/components/admin_console/boolean_setting.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Setting from './setting.jsx';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
export default class BooleanSetting extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Setting label={this.props.label}>
|
||||
<label className='radio-inline'>
|
||||
<input
|
||||
type='radio'
|
||||
value='true'
|
||||
checked={this.props.currentValue}
|
||||
onChange={this.props.handleChange}
|
||||
disabled={this.props.isDisabled}
|
||||
/>
|
||||
{this.props.trueText}
|
||||
</label>
|
||||
<label className='radio-inline'>
|
||||
<input
|
||||
type='radio'
|
||||
value='false'
|
||||
checked={!this.props.currentValue}
|
||||
onChange={this.props.handleChange}
|
||||
disabled={this.props.isDisabled}
|
||||
/>
|
||||
{this.props.falseText}
|
||||
</label>
|
||||
{this.props.helpText}
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
}
|
||||
BooleanSetting.defaultProps = {
|
||||
trueText: (
|
||||
<FormattedMessage
|
||||
id='admin.ldap.true'
|
||||
defaultMessage='true'
|
||||
/>
|
||||
),
|
||||
falseText: (
|
||||
<FormattedMessage
|
||||
id='admin.ldap.false'
|
||||
defaultMessage='false'
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
BooleanSetting.propTypes = {
|
||||
label: React.PropTypes.node.isRequired,
|
||||
currentValue: React.PropTypes.bool.isRequired,
|
||||
trueText: React.PropTypes.node,
|
||||
falseText: React.PropTypes.node,
|
||||
isDisabled: React.PropTypes.bool.isRequired,
|
||||
handleChange: React.PropTypes.func.isRequired,
|
||||
helpText: React.PropTypes.node.isRequired
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import * as Utils from 'utils/utils.jsx';
|
||||
|
||||
import DropdownSetting from './dropdown_setting.jsx';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
const CONNECTION_SECURITY_HELP_TEXT = (
|
||||
<div className='help-text'>
|
||||
<table
|
||||
className='table table-bordered'
|
||||
cellPadding='5'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityNone'
|
||||
defaultMessage='None'
|
||||
/>
|
||||
</td>
|
||||
<td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityNoneDescription'
|
||||
defaultMessage='Mattermost will connect over an unsecure connection.'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityTls'
|
||||
defaultMessage='TLS'
|
||||
/>
|
||||
</td>
|
||||
<td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityTlsDescription'
|
||||
defaultMessage='Encrypts the communication between Mattermost and your server.'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityStart'
|
||||
defaultMessage='STARTTLS'
|
||||
/>
|
||||
</td>
|
||||
<td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityStartDescription'
|
||||
defaultMessage='Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default class ConnectionSecurityDropdownSetting extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<DropdownSetting
|
||||
values={[
|
||||
{value: '', text: Utils.localizeMessage('admin.connectionSecurityNone', 'None')},
|
||||
{value: 'TLS', text: Utils.localizeMessage('admin.connectionSecurityTls', 'TLS (Recommended)')},
|
||||
{value: 'STARTTLS', text: Utils.localizeMessage('admin.connectionSecurityStart')}
|
||||
]}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='admin.connectionSecurityTitle'
|
||||
defaultMessage='Connection Security:'
|
||||
/>
|
||||
}
|
||||
currentValue={this.props.currentValue}
|
||||
handleChange={this.props.handleChange}
|
||||
isDisabled={this.props.isDisabled}
|
||||
helpText={CONNECTION_SECURITY_HELP_TEXT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
ConnectionSecurityDropdownSetting.defaultProps = {
|
||||
};
|
||||
|
||||
ConnectionSecurityDropdownSetting.propTypes = {
|
||||
currentValue: React.PropTypes.string.isRequired,
|
||||
handleChange: React.PropTypes.func.isRequired,
|
||||
isDisabled: React.PropTypes.bool.isRequired
|
||||
};
|
||||
47
webapp/components/admin_console/dropdown_setting.jsx
Normal file
47
webapp/components/admin_console/dropdown_setting.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Setting from './setting.jsx';
|
||||
|
||||
export default class DropdownSetting extends React.Component {
|
||||
render() {
|
||||
const options = [];
|
||||
for (const {value, text} of this.props.values) {
|
||||
options.push(
|
||||
<option
|
||||
value={value}
|
||||
key={value}
|
||||
>
|
||||
{text}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Setting label={this.props.label}>
|
||||
<select
|
||||
className='form-control'
|
||||
value={this.props.currentValue}
|
||||
onChange={this.props.handleChange}
|
||||
disabled={this.props.isDisabled}
|
||||
>
|
||||
{options}
|
||||
</select>
|
||||
{this.props.helpText}
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
}
|
||||
DropdownSetting.defaultProps = {
|
||||
};
|
||||
|
||||
DropdownSetting.propTypes = {
|
||||
values: React.PropTypes.array.isRequired,
|
||||
label: React.PropTypes.node.isRequired,
|
||||
currentValue: React.PropTypes.string.isRequired,
|
||||
handleChange: React.PropTypes.func.isRequired,
|
||||
isDisabled: React.PropTypes.bool.isRequired,
|
||||
helpText: React.PropTypes.node.isRequired
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import ReactDOM from 'react-dom';
|
||||
import * as Client from 'utils/client.jsx';
|
||||
import * as AsyncClient from 'utils/async_client.jsx';
|
||||
import crypto from 'crypto';
|
||||
import ConnectionSecurityDropdownSetting from './connection_security_dropdown_setting.jsx';
|
||||
|
||||
import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl';
|
||||
|
||||
@@ -34,18 +35,6 @@ var holders = defineMessages({
|
||||
id: 'admin.email.smtpPortExample',
|
||||
defaultMessage: 'Ex: "25", "465"'
|
||||
},
|
||||
connectionSecurityNone: {
|
||||
id: 'admin.email.connectionSecurityNone',
|
||||
defaultMessage: 'None'
|
||||
},
|
||||
connectionSecurityTls: {
|
||||
id: 'admin.email.connectionSecurityTls',
|
||||
defaultMessage: 'TLS (Recommended)'
|
||||
},
|
||||
connectionSecurityStart: {
|
||||
id: 'admin.email.connectionSecurityStart',
|
||||
defaultMessage: 'STARTTLS'
|
||||
},
|
||||
inviteSaltExample: {
|
||||
id: 'admin.email.inviteSaltExample',
|
||||
defaultMessage: 'Ex "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"'
|
||||
@@ -96,7 +85,8 @@ class EmailSettings extends React.Component {
|
||||
serverError: null,
|
||||
emailSuccess: null,
|
||||
emailFail: null,
|
||||
pushNotificationContents: this.props.config.EmailSettings.PushNotificationContents
|
||||
pushNotificationContents: this.props.config.EmailSettings.PushNotificationContents,
|
||||
connectionSecurity: this.props.config.EmailSettings.ConnectionSecurity
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,7 +128,7 @@ class EmailSettings extends React.Component {
|
||||
config.EmailSettings.SMTPPort = ReactDOM.findDOMNode(this.refs.SMTPPort).value.trim();
|
||||
config.EmailSettings.SMTPUsername = ReactDOM.findDOMNode(this.refs.SMTPUsername).value.trim();
|
||||
config.EmailSettings.SMTPPassword = ReactDOM.findDOMNode(this.refs.SMTPPassword).value.trim();
|
||||
config.EmailSettings.ConnectionSecurity = ReactDOM.findDOMNode(this.refs.ConnectionSecurity).value.trim();
|
||||
config.EmailSettings.ConnectionSecurity = this.state.connectionSecurity.trim();
|
||||
|
||||
config.EmailSettings.InviteSalt = ReactDOM.findDOMNode(this.refs.InviteSalt).value.trim();
|
||||
if (config.EmailSettings.InviteSalt === '') {
|
||||
@@ -703,61 +693,13 @@ class EmailSettings extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionSecurityDropdownSetting
|
||||
currentValue={this.state.connectionSecurity}
|
||||
handleChange={(e) => this.setState({connectionSecurity: e.target.value, saveNeeded: true})}
|
||||
isDisabled={!this.state.sendEmailNotifications}
|
||||
/>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
htmlFor='ConnectionSecurity'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.email.connectionSecurityTitle'
|
||||
defaultMessage='Connection Security:'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-sm-8'>
|
||||
<select
|
||||
className='form-control'
|
||||
id='ConnectionSecurity'
|
||||
ref='ConnectionSecurity'
|
||||
defaultValue={this.props.config.EmailSettings.ConnectionSecurity}
|
||||
onChange={this.handleChange}
|
||||
disabled={!this.state.sendEmailNotifications}
|
||||
>
|
||||
<option value=''>{formatMessage(holders.connectionSecurityNone)}</option>
|
||||
<option value='TLS'>{formatMessage(holders.connectionSecurityTls)}</option>
|
||||
<option value='STARTTLS'>{formatMessage(holders.connectionSecurityStart)}</option>
|
||||
</select>
|
||||
<div className='help-text'>
|
||||
<table
|
||||
className='table table-bordered'
|
||||
cellPadding='5'
|
||||
>
|
||||
<tbody>
|
||||
<tr><td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.email.connectionSecurityNone'
|
||||
defaultMessage='None'
|
||||
/>
|
||||
</td><td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.email.connectionSecurityNoneDescription'
|
||||
defaultMessage='Mattermost will send email over an unsecure connection.'
|
||||
/>
|
||||
</td></tr>
|
||||
<tr><td className='help-text'>{'TLS'}</td><td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.email.connectionSecurityTlsDescription'
|
||||
defaultMessage='Encrypts the communication between Mattermost and your email server.'
|
||||
/>
|
||||
</td></tr>
|
||||
<tr><td className='help-text'>{'STARTTLS'}</td><td className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.email.connectionSecurityStartDescription'
|
||||
defaultMessage='Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'
|
||||
/>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className='help-text'>
|
||||
<button
|
||||
className='btn btn-default'
|
||||
|
||||
@@ -8,6 +8,8 @@ import * as Utils from 'utils/utils.jsx';
|
||||
import * as AsyncClient from 'utils/async_client.jsx';
|
||||
|
||||
import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
|
||||
import ConnectionSecurityDropdownSetting from './connection_security_dropdown_setting.jsx';
|
||||
import BooleanSetting from './boolean_setting.jsx';
|
||||
|
||||
const DEFAULT_LDAP_PORT = 389;
|
||||
const DEFAULT_QUERY_TIMEOUT = 60;
|
||||
@@ -26,7 +28,9 @@ class LdapSettings extends React.Component {
|
||||
this.state = {
|
||||
saveNeeded: false,
|
||||
serverError: null,
|
||||
enable: this.props.config.LdapSettings.Enable
|
||||
enable: this.props.config.LdapSettings.Enable,
|
||||
connectionSecurity: this.props.config.LdapSettings.ConnectionSecurity,
|
||||
skipCertificateVerification: this.props.config.LdapSettings.SkipCertificateVerification
|
||||
};
|
||||
}
|
||||
handleChange() {
|
||||
@@ -61,6 +65,8 @@ class LdapSettings extends React.Component {
|
||||
config.LdapSettings.UsernameAttribute = this.refs.UsernameAttribute.value.trim();
|
||||
config.LdapSettings.IdAttribute = this.refs.IdAttribute.value.trim();
|
||||
config.LdapSettings.UserFilter = this.refs.UserFilter.value.trim();
|
||||
config.LdapSettings.ConnectionSecurity = this.state.connectionSecurity.trim();
|
||||
config.LdapSettings.SkipCertificateVerification = this.state.skipCertificateVerification;
|
||||
|
||||
let QueryTimeout = DEFAULT_QUERY_TIMEOUT;
|
||||
if (!isNaN(parseInt(ReactDOM.findDOMNode(this.refs.QueryTimeout).value, 10))) {
|
||||
@@ -251,6 +257,11 @@ class LdapSettings extends React.Component {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionSecurityDropdownSetting
|
||||
currentValue={this.state.connectionSecurity}
|
||||
handleChange={(e) => this.setState({connectionSecurity: e.target.value, saveNeeded: true})}
|
||||
isDisabled={!this.state.enable}
|
||||
/>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
@@ -512,6 +523,25 @@ class LdapSettings extends React.Component {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BooleanSetting
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='admin.ldap.skipCertificateVerification'
|
||||
defaultMessage='Skip Certificate Verification'
|
||||
/>
|
||||
}
|
||||
currentValue={this.state.skipCertificateVerification}
|
||||
isDisabled={!this.state.enable}
|
||||
handleChange={(e) => this.setState({skipCertificateVerification: e.target.value.trim() === 'true', saveNeeded: true})}
|
||||
helpText={
|
||||
<p className='help-text'>
|
||||
<FormattedMessage
|
||||
id='admin.ldap.skipCertificateVerificationDesc'
|
||||
defaultMessage='Skips the certificate verificaiton step for TLS or STARTTLS connections. Not recommented for production enviroments where TLS is required. For testing only.'
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
|
||||
28
webapp/components/admin_console/setting.jsx
Normal file
28
webapp/components/admin_console/setting.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default class Setting extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label col-sm-4'
|
||||
>
|
||||
{this.props.label}
|
||||
</label>
|
||||
<div className='col-sm-8'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Setting.defaultProps = {
|
||||
};
|
||||
|
||||
Setting.propTypes = {
|
||||
label: React.PropTypes.node.isRequired,
|
||||
children: React.PropTypes.node.isRequired
|
||||
};
|
||||
@@ -124,14 +124,14 @@
|
||||
"admin.email.allowSignupTitle": "Allow Sign Up With Email: ",
|
||||
"admin.email.allowUsernameSignInDescription": "When true, Mattermost allows users to sign in using their username and password. This setting is typically only used when email verification is disabled.",
|
||||
"admin.email.allowUsernameSignInTitle": "Allow Sign In With Username: ",
|
||||
"admin.email.connectionSecurityNone": "None",
|
||||
"admin.email.connectionSecurityNoneDescription": "Mattermost will send email over an unsecure connection.",
|
||||
"admin.email.connectionSecurityStart": "STARTTLS",
|
||||
"admin.email.connectionSecurityStartDescription": "Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.",
|
||||
"admin.email.connectionSecurityTest": "Test Connection",
|
||||
"admin.email.connectionSecurityTitle": "Connection Security:",
|
||||
"admin.email.connectionSecurityTls": "TLS (Recommended)",
|
||||
"admin.email.connectionSecurityTlsDescription": "Encrypts the communication between Mattermost and your email server.",
|
||||
"admin.connectionSecurityNone": "None",
|
||||
"admin.connectionSecurityNoneDescription": "Mattermost will connect over an unsecure connection.",
|
||||
"admin.connectionSecurityStart": "STARTTLS",
|
||||
"admin.connectionSecurityStartDescription": "Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.",
|
||||
"admin.connectionSecurityTest": "Test Connection",
|
||||
"admin.connectionSecurityTitle": "Connection Security:",
|
||||
"admin.connectionSecurityTls": "TLS",
|
||||
"admin.connectionSecurityTlsDescription": "Encrypts the communication between Mattermost and your server.",
|
||||
"admin.email.emailFail": "Connection unsuccessful: {error}",
|
||||
"admin.email.emailSettings": "Email Settings",
|
||||
"admin.email.emailSuccess": "No errors were reported while sending an email. Please check your inbox to make sure.",
|
||||
@@ -279,6 +279,8 @@
|
||||
"admin.ldap.queryDesc": "The timeout value for queries to the LDAP server. Increase if you are getting timeout errors caused by a slow LDAP server.",
|
||||
"admin.ldap.queryEx": "Ex \"60\"",
|
||||
"admin.ldap.queryTitle": "Query Timeout (seconds):",
|
||||
"admin.ldap.skipCertificateVerification": "Skip Vertificate Verification",
|
||||
"admin.ldap.skipCertificateVerificationDesc": "Skips the certificate verificaiton step for TLS or STARTTLS connections. Not recommented for production enviroments where TLS is required. For testing only.",
|
||||
"admin.ldap.save": "Save",
|
||||
"admin.ldap.saving": "Saving Config...",
|
||||
"admin.ldap.serverDesc": "The domain or IP address of LDAP server.",
|
||||
@@ -1423,4 +1425,4 @@
|
||||
"web.footer.terms": "Terms",
|
||||
"web.header.back": "Back",
|
||||
"web.root.singup_info": "All team communication in one place, searchable and accessible anywhere"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user