diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | Makefile | 4 | ||||
-rw-r--r-- | api/api.go | 1 | ||||
-rw-r--r-- | api/context.go | 1 | ||||
-rw-r--r-- | api/file.go | 20 | ||||
-rw-r--r-- | api/license.go | 100 | ||||
-rw-r--r-- | api/user.go | 33 | ||||
-rw-r--r-- | config/config.json | 23 | ||||
-rw-r--r-- | mattermost.go | 7 | ||||
-rw-r--r-- | model/license.go | 85 | ||||
-rw-r--r-- | model/license_test.go | 34 | ||||
-rw-r--r-- | utils/license.go | 157 | ||||
-rw-r--r-- | utils/license_test.go | 50 | ||||
-rw-r--r-- | web/react/components/about_build_modal.jsx | 21 | ||||
-rw-r--r-- | web/react/components/admin_console/admin_controller.jsx | 3 | ||||
-rw-r--r-- | web/react/components/admin_console/admin_sidebar.jsx | 32 | ||||
-rw-r--r-- | web/react/components/admin_console/ldap_settings.jsx | 32 | ||||
-rw-r--r-- | web/react/components/admin_console/license_settings.jsx | 237 | ||||
-rw-r--r-- | web/react/components/file_upload.jsx | 27 | ||||
-rw-r--r-- | web/react/utils/client.jsx | 35 | ||||
-rw-r--r-- | web/react/utils/utils.jsx | 31 | ||||
-rw-r--r-- | web/sass-files/sass/partials/_admin-console.scss | 5 | ||||
-rw-r--r-- | web/templates/head.html | 1 | ||||
-rw-r--r-- | web/web.go | 2 |
24 files changed, 900 insertions, 43 deletions
diff --git a/.gitignore b/.gitignore index dab6b8373..5d6fc98e5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ web/static/js/bundle*.js web/static/js/bundle*.js.map web/static/js/libs*.js +config/active.dat + # Build Targets .prepare .prepare-go @@ -266,6 +266,9 @@ run: start-docker .prepare-go .prepare-jsx jq -s '.[0] * .[1]' ./config/config.json $(ENTERPRISE_DIR)/config/enterprise-config-additions.json > config.json.tmp; \ mv config.json.tmp ./config/config.json; \ sed -e '/\/\/ENTERPRISE_IMPORTS/ {' -e 'r $(ENTERPRISE_DIR)/imports' -e 'd' -e '}' -i'.bak' mattermost.go; \ + sed -i'.bak' 's|_BUILD_ENTERPRISE_READY_|true|g' ./model/version.go; \ + else \ + sed -i'.bak' 's|_BUILD_ENTERPRISE_READY_|false|g' ./model/version.go; \ fi @echo Starting go web server @@ -299,6 +302,7 @@ stop: @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ mv ./config/config.json.bak ./config/config.json 2> /dev/null || true; \ mv ./mattermost.go.bak ./mattermost.go 2> /dev/null || true; \ + mv ./model/version.go.bak ./model/version.go 2> /dev/null || true; \ fi setup-mac: diff --git a/api/api.go b/api/api.go index a6bb22982..f29063fe1 100644 --- a/api/api.go +++ b/api/api.go @@ -46,6 +46,7 @@ func InitApi() { InitOAuth(r) InitWebhook(r) InitPreference(r) + InitLicense(r) templatesDir := utils.FindDir("api/templates") l4g.Debug("Parsing server templates at %v", templatesDir) diff --git a/api/context.go b/api/context.go index 561884c14..e8ec6576d 100644 --- a/api/context.go +++ b/api/context.go @@ -35,6 +35,7 @@ type Page struct { TemplateName string Props map[string]string ClientCfg map[string]string + ClientLicense map[string]string User *model.User Team *model.Team Channel *model.Channel diff --git a/api/file.go b/api/file.go index d023515af..46e81691e 100644 --- a/api/file.go +++ b/api/file.go @@ -541,12 +541,8 @@ func writeFile(f []byte, path string) *model.AppError { return model.NewAppError("writeFile", "Encountered an error writing to S3", err.Error()) } } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { - if err := os.MkdirAll(filepath.Dir(utils.Cfg.FileSettings.Directory+path), 0774); err != nil { - return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error()) - } - - if err := ioutil.WriteFile(utils.Cfg.FileSettings.Directory+path, f, 0644); err != nil { - return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error()) + if err := writeFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil { + return err } } else { return model.NewAppError("writeFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") @@ -555,6 +551,18 @@ func writeFile(f []byte, path string) *model.AppError { return nil } +func writeFileLocally(f []byte, path string) *model.AppError { + if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil { + return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error()) + } + + if err := ioutil.WriteFile(path, f, 0644); err != nil { + return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error()) + } + + return nil +} + func readFile(path string) ([]byte, *model.AppError) { if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { diff --git a/api/license.go b/api/license.go new file mode 100644 index 000000000..06bde2b6c --- /dev/null +++ b/api/license.go @@ -0,0 +1,100 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "bytes" + l4g "code.google.com/p/log4go" + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + "io" + "net/http" + "strings" +) + +func InitLicense(r *mux.Router) { + l4g.Debug("Initializing license api routes") + + sr := r.PathPrefix("/license").Subrouter() + sr.Handle("/add", ApiAdminSystemRequired(addLicense)).Methods("POST") + sr.Handle("/remove", ApiAdminSystemRequired(removeLicense)).Methods("POST") +} + +func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("attempt") + err := r.ParseMultipartForm(model.MAX_FILE_SIZE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + m := r.MultipartForm + + fileArray, ok := m.File["license"] + if !ok { + c.Err = model.NewAppError("addLicense", "No file under 'license' in request", "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + if len(fileArray) <= 0 { + c.Err = model.NewAppError("addLicense", "Empty array under 'license' in request", "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + fileData := fileArray[0] + + file, err := fileData.Open() + defer file.Close() + if err != nil { + c.Err = model.NewAppError("addLicense", "Could not open license file", err.Error()) + return + } + + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + + data := buf.Bytes() + + var license *model.License + if success, licenseStr := utils.ValidateLicense(data); success { + license = model.LicenseFromJson(strings.NewReader(licenseStr)) + + if ok := utils.SetLicense(license); !ok { + c.LogAudit("failed - expired or non-started license") + c.Err = model.NewAppError("addLicense", "License is either expired or has not yet started.", "") + return + } + + if err := writeFileLocally(data, utils.LicenseLocation()); err != nil { + c.LogAudit("failed - could not save license file") + c.Err = model.NewAppError("addLicense", "License did not save properly.", "path="+utils.LicenseLocation()) + utils.RemoveLicense() + return + } + } else { + c.LogAudit("failed - invalid license") + c.Err = model.NewAppError("addLicense", "Invalid license file.", "") + return + } + + c.LogAudit("success") + w.Write([]byte(license.ToJson())) +} + +func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("") + + if ok := utils.RemoveLicense(); !ok { + c.LogAudit("failed - could not remove license file") + c.Err = model.NewAppError("removeLicense", "License did not remove properly.", "") + return + } + + rdata := map[string]string{} + rdata["status"] = "ok" + w.Write([]byte(model.MapToJson(rdata))) +} diff --git a/api/user.go b/api/user.go index d014ab995..a6b4fb654 100644 --- a/api/user.go +++ b/api/user.go @@ -122,6 +122,11 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) { user.EmailVerified = true } + if !CheckUserDomain(user, utils.Cfg.TeamSettings.RestrictCreationToDomains) { + c.Err = model.NewAppError("createUser", "The email you provided does not belong to an accepted domain. Please contact your administrator or sign up with a different email.", "") + return + } + ruser, err := CreateUser(team, user) if err != nil { c.Err = err @@ -136,19 +141,29 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) { } +func CheckUserDomain(user *model.User, domains string) bool { + if len(domains) == 0 { + return true + } + + domainArray := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(domains, "@", " ", -1), ",", " ", -1)))) + + matched := false + for _, d := range domainArray { + if strings.HasSuffix(user.Email, "@"+d) { + matched = true + break + } + } + + return matched +} + func IsVerifyHashRequired(user *model.User, team *model.Team, hash string) bool { shouldVerifyHash := true if team.Type == model.TEAM_INVITE && len(team.AllowedDomains) > 0 && len(hash) == 0 && user != nil { - domains := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(team.AllowedDomains, "@", " ", -1), ",", " ", -1)))) - - matched := false - for _, d := range domains { - if strings.HasSuffix(user.Email, "@"+d) { - matched = true - break - } - } + matched := CheckUserDomain(user, team.AllowedDomains) if matched { shouldVerifyHash = false diff --git a/config/config.json b/config/config.json index 076f795cc..907b66828 100644 --- a/config/config.json +++ b/config/config.json @@ -107,5 +107,28 @@ "AuthEndpoint": "", "TokenEndpoint": "", "UserApiEndpoint": "" + }, + "GoogleSettings": { + "Enable": false, + "Secret": "", + "Id": "", + "Scope": "", + "AuthEndpoint": "", + "TokenEndpoint": "", + "UserApiEndpoint": "" + }, + "LdapSettings": { + "Enable": false, + "LdapServer": null, + "LdapPort": 389, + "BaseDN": null, + "BindUsername": null, + "BindPassword": null, + "FirstNameAttribute": null, + "LastNameAttribute": null, + "EmailAttribute": null, + "UsernameAttribute": null, + "IdAttribute": null, + "QueryTimeout": 60 } }
\ No newline at end of file diff --git a/mattermost.go b/mattermost.go index f86af76e3..7ebda451f 100644 --- a/mattermost.go +++ b/mattermost.go @@ -31,7 +31,10 @@ import ( _ "github.com/go-ldap/ldap" ) -//ENTERPRISE_IMPORTS +import ( + _ "github.com/mattermost/enterprise/oauth/google" + _ "github.com/mattermost/enterprise/ldap" +) var flagCmdCreateTeam bool var flagCmdCreateUser bool @@ -67,6 +70,8 @@ func main() { api.InitApi() web.InitWeb() + utils.LoadLicense() + if flagRunCmds { runCmds() } else { diff --git a/model/license.go b/model/license.go new file mode 100644 index 000000000..a271b46b7 --- /dev/null +++ b/model/license.go @@ -0,0 +1,85 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type License struct { + Id string `json:"id"` + IssuedAt int64 `json:"issued_at"` + StartsAt int64 `json:"starts_at"` + ExpiresAt int64 `json:"expires_at"` + Customer *Customer `json:"customer"` + Features *Features `json:"features"` +} + +type Customer struct { + Id string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + PhoneNumber string `json:"phone_number"` +} + +type Features struct { + Users *int `json:"users"` + LDAP *bool `json:"ldap"` + GoogleSSO *bool `json:"google_sso"` +} + +func (f *Features) SetDefaults() { + if f.Users == nil { + f.Users = new(int) + *f.Users = 0 + } + + if f.LDAP == nil { + f.LDAP = new(bool) + *f.LDAP = true + } + + if f.GoogleSSO == nil { + f.GoogleSSO = new(bool) + *f.GoogleSSO = true + } +} + +func (l *License) IsExpired() bool { + now := GetMillis() + if l.ExpiresAt < now { + return true + } + return false +} + +func (l *License) IsStarted() bool { + now := GetMillis() + if l.StartsAt < now { + return true + } + return false +} + +func (l *License) ToJson() string { + b, err := json.Marshal(l) + if err != nil { + return "" + } else { + return string(b) + } +} + +func LicenseFromJson(data io.Reader) *License { + decoder := json.NewDecoder(data) + var o License + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/license_test.go b/model/license_test.go new file mode 100644 index 000000000..25c74a2e3 --- /dev/null +++ b/model/license_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "testing" +) + +func TestLicenseExpired(t *testing.T) { + l1 := License{} + l1.ExpiresAt = GetMillis() - 1000 + if !l1.IsExpired() { + t.Fatal("license should be expired") + } + + l1.ExpiresAt = GetMillis() + 10000 + if l1.IsExpired() { + t.Fatal("license should not be expired") + } +} + +func TestLicenseStarted(t *testing.T) { + l1 := License{} + l1.StartsAt = GetMillis() - 1000 + if !l1.IsStarted() { + t.Fatal("license should be started") + } + + l1.StartsAt = GetMillis() + 10000 + if l1.IsStarted() { + t.Fatal("license should not be started") + } +} diff --git a/utils/license.go b/utils/license.go new file mode 100644 index 000000000..84d5bae02 --- /dev/null +++ b/utils/license.go @@ -0,0 +1,157 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package utils + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + l4g "code.google.com/p/log4go" + + "github.com/mattermost/platform/model" +) + +const ( + LICENSE_FILENAME = "active.dat" +) + +var IsLicensed bool = false +var License *model.License = &model.License{} +var ClientLicense map[string]string = make(map[string]string) + +// test public key +var publicKey []byte = []byte(`-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3/k3Al9q1Xe+xngQ/yGn +0suaJopea3Cpf6NjIHdO8sYTwLlxqt0Mdb+qBR9LbCjZfcNmqc5mZONvsyCEoN/5 +VoLdlv1m9ao2BSAWphUxE2CPdUWdLOsDbQWliSc5//UhiYeR+67Xxon0Hg0LKXF6 +PumRIWQenRHJWqlUQZ147e7/1v9ySVRZksKpvlmMDzgq+kCH/uyM1uVP3z7YXhlN +K7vSSQYbt4cghvWQxDZFwpLlsChoY+mmzClgq+Yv6FLhj4/lk94twdOZau/AeZFJ +NxpC+5KFhU+xSeeklNqwCgnlOyZ7qSTxmdJHb+60SwuYnnGIYzLJhY4LYDr4J+KR +1wIDAQAB +-----END PUBLIC KEY-----`) + +func LoadLicense() { + file, err := os.Open(LicenseLocation()) + if err != nil { + l4g.Warn("Unable to open/find license file") + return + } + defer file.Close() + + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + + if success, licenseStr := ValidateLicense(buf.Bytes()); success { + license := model.LicenseFromJson(strings.NewReader(licenseStr)) + SetLicense(license) + } + + l4g.Warn("No valid enterprise license found") +} + +func SetLicense(license *model.License) bool { + license.Features.SetDefaults() + + if !license.IsExpired() && license.IsStarted() { + License = license + IsLicensed = true + ClientLicense = getClientLicense(license) + return true + } + + return false +} + +func LicenseLocation() string { + return filepath.Dir(CfgFileName) + "/" + LICENSE_FILENAME +} + +func RemoveLicense() bool { + License = &model.License{} + IsLicensed = false + ClientLicense = getClientLicense(License) + + if err := os.Remove(LicenseLocation()); err != nil { + l4g.Error("Unable to remove license file, err=%v", err.Error()) + return false + } + + return true +} + +func ValidateLicense(signed []byte) (bool, string) { + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(signed))) + + _, err := base64.StdEncoding.Decode(decoded, signed) + if err != nil { + l4g.Error("Encountered error decoding license, err=%v", err.Error()) + return false, "" + } + + if len(decoded) <= 256 { + l4g.Error("Signed license not long enough") + return false, "" + } + + // remove null terminator + if decoded[len(decoded)-1] == byte(0) { + decoded = decoded[:len(decoded)-1] + } + + plaintext := decoded[:len(decoded)-256] + signature := decoded[len(decoded)-256:] + + block, _ := pem.Decode(publicKey) + + public, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + l4g.Error("Encountered error signing license, err=%v", err.Error()) + return false, "" + } + + rsaPublic := public.(*rsa.PublicKey) + + h := sha512.New() + h.Write(plaintext) + d := h.Sum(nil) + + err = rsa.VerifyPKCS1v15(rsaPublic, crypto.SHA512, d, signature) + if err != nil { + l4g.Error("Invalid signature, err=%v", err.Error()) + return false, "" + } + + return true, string(plaintext) +} + +func getClientLicense(l *model.License) map[string]string { + props := make(map[string]string) + + props["IsLicensed"] = strconv.FormatBool(IsLicensed) + + if IsLicensed { + props["Users"] = strconv.Itoa(*l.Features.Users) + props["LDAP"] = strconv.FormatBool(*l.Features.LDAP) + props["GoogleSSO"] = strconv.FormatBool(*l.Features.GoogleSSO) + props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10) + props["StartsAt"] = strconv.FormatInt(l.StartsAt, 10) + props["ExpiresAt"] = strconv.FormatInt(l.ExpiresAt, 10) + props["Name"] = l.Customer.Name + props["Email"] = l.Customer.Email + props["Company"] = l.Customer.Company + props["PhoneNumber"] = l.Customer.PhoneNumber + } + + return props +} diff --git a/utils/license_test.go b/utils/license_test.go new file mode 100644 index 000000000..826107032 --- /dev/null +++ b/utils/license_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package utils + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestSetLicense(t *testing.T) { + l1 := &model.License{} + l1.Features = &model.Features{} + l1.Customer = &model.Customer{} + l1.StartsAt = model.GetMillis() - 1000 + l1.ExpiresAt = model.GetMillis() + 100000 + if ok := SetLicense(l1); !ok { + t.Fatal("license should have worked") + } + + l2 := &model.License{} + l2.Features = &model.Features{} + l2.Customer = &model.Customer{} + l2.StartsAt = model.GetMillis() - 1000 + l2.ExpiresAt = model.GetMillis() - 100 + if ok := SetLicense(l2); ok { + t.Fatal("license should have failed") + } + + l3 := &model.License{} + l3.Features = &model.Features{} + l3.Customer = &model.Customer{} + l3.StartsAt = model.GetMillis() + 10000 + l3.ExpiresAt = model.GetMillis() + 100000 + if ok := SetLicense(l3); ok { + t.Fatal("license should have failed") + } +} + +func TestValidateLicense(t *testing.T) { + b1 := []byte("junk") + if ok, _ := ValidateLicense(b1); ok { + t.Fatal("should have failed - bad license") + } + + b2 := []byte("junkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunk") + if ok, _ := ValidateLicense(b2); ok { + t.Fatal("should have failed - bad license") + } +} diff --git a/web/react/components/about_build_modal.jsx b/web/react/components/about_build_modal.jsx index 3143bec22..f70027498 100644 --- a/web/react/components/about_build_modal.jsx +++ b/web/react/components/about_build_modal.jsx @@ -15,6 +15,19 @@ export default class AboutBuildModal extends React.Component { render() { const config = global.window.mm_config; + const license = global.window.mm_license; + + let title = 'Team Edition'; + let licensee; + if (config.BuildEnterpriseReady === 'true' && license.IsLicensed === 'true') { + title = 'Enterprise Edition'; + licensee = ( + <div className='row form-group'> + <div className='col-sm-3 info__label'>{'Licensed by:'}</div> + <div className='col-sm-9'>{license.Company}</div> + </div> + ); + } return ( <Modal @@ -22,9 +35,15 @@ export default class AboutBuildModal extends React.Component { onHide={this.doHide} > <Modal.Header closeButton={true}> - <Modal.Title>{`Mattermost ${config.Version}`}</Modal.Title> + <Modal.Title>{'About Mattermost'}</Modal.Title> </Modal.Header> <Modal.Body> + <h4>{`Mattermost ${title}`}</h4> + {licensee} + <div className='row form-group'> + <div className='col-sm-3 info__label'>{'Version:'}</div> + <div className='col-sm-9'>{config.Version}</div> + </div> <div className='row form-group'> <div className='col-sm-3 info__label'>{'Build Number:'}</div> <div className='col-sm-9'>{config.BuildNumber}</div> diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 32b2e9bb7..0f85c238d 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -22,6 +22,7 @@ import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; import TeamUsersTab from './team_users.jsx'; import TeamAnalyticsTab from './team_analytics.jsx'; import LdapSettingsTab from './ldap_settings.jsx'; +import LicenseSettingsTab from './license_settings.jsx'; export default class AdminController extends React.Component { constructor(props) { @@ -154,6 +155,8 @@ export default class AdminController extends React.Component { tab = <LegalAndSupportSettingsTab config={this.state.config} />; } else if (this.state.selected === 'ldap_settings') { tab = <LdapSettingsTab config={this.state.config} />; + } else if (this.state.selected === 'license') { + tab = <LicenseSettingsTab />; } else if (this.state.selected === 'team_users') { if (this.state.teams) { tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 1279f4d22..5a5eaa055 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -155,6 +155,36 @@ export default class AdminSidebar extends React.Component { } } + let ldapSettings; + let licenseSettings; + if (global.window.mm_config.BuildEnterpriseReady === 'true') { + if (global.window.mm_license.IsLicensed === 'true') { + ldapSettings = ( + <li> + <a + href='#' + className={this.isSelected('ldap_settings')} + onClick={this.handleClick.bind(this, 'ldap_settings', null)} + > + {'LDAP Settings'} + </a> + </li> + ); + } + + licenseSettings = ( + <li> + <a + href='#' + className={this.isSelected('license')} + onClick={this.handleClick.bind(this, 'license', null)} + > + {'Edition and License'} + </a> + </li> + ); + } + return ( <div className='sidebar--left sidebar--collapsable'> <div> @@ -252,6 +282,7 @@ export default class AdminSidebar extends React.Component { {'GitLab Settings'} </a> </li> + {ldapSettings} <li> <a href='#' @@ -300,6 +331,7 @@ export default class AdminSidebar extends React.Component { </li> </ul> <ul className='nav nav__sub-menu padded'> + {licenseSettings} <li> <a href='#' diff --git a/web/react/components/admin_console/ldap_settings.jsx b/web/react/components/admin_console/ldap_settings.jsx index 6e3da2f72..1447f3bd7 100644 --- a/web/react/components/admin_console/ldap_settings.jsx +++ b/web/react/components/admin_console/ldap_settings.jsx @@ -90,14 +90,41 @@ export default class LdapSettings extends React.Component { saveClass = 'btn btn-primary'; } - return ( - <div className='wrapper--fixed'> + const licenseEnabled = global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.LDAP === 'true'; + + let bannerContent; + if (licenseEnabled) { + bannerContent = ( <div className='banner'> <div className='banner__content'> <h4 className='banner__heading'>{'Note:'}</h4> <p>{'If a user attribute changes on the LDAP server it will be updated the next time the user enters their credentials to log in to Mattermost. This includes if a user is made inactive or removed from an LDAP server. Synchronization with LDAP servers is planned in a future release.'}</p> </div> </div> + ); + } else { + bannerContent = ( + <div className='banner warning'> + <div className='banner__content'> + <h4 className='banner__heading'>{'Note:'}</h4> + <p> + {'LDAP is an enterprise feature. Your current license does not support LDAP. Click '} + <a + href='http://mattermost.com' + target='_blank' + > + {'here'} + </a> + {' for information and pricing on enterprise licenses.'} + </p> + </div> + </div> + ); + } + + return ( + <div className='wrapper--fixed'> + {bannerContent} <h3>{'LDAP Settings'}</h3> <form className='form-horizontal' @@ -119,6 +146,7 @@ export default class LdapSettings extends React.Component { ref='Enable' defaultChecked={this.props.config.LdapSettings.Enable} onChange={this.handleEnable} + disabled={!licenseEnabled} /> {'true'} </label> diff --git a/web/react/components/admin_console/license_settings.jsx b/web/react/components/admin_console/license_settings.jsx new file mode 100644 index 000000000..ba953f3bd --- /dev/null +++ b/web/react/components/admin_console/license_settings.jsx @@ -0,0 +1,237 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Utils from '../../utils/utils.jsx'; +import * as Client from '../../utils/client.jsx'; + +export default class LicenseSettings extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleRemove = this.handleRemove.bind(this); + + this.state = { + fileSelected: false, + serverError: null + }; + } + + handleChange() { + const element = $(ReactDOM.findDOMNode(this.refs.fileInput)); + if (element.prop('files').length > 0) { + this.setState({fileSelected: true}); + } + } + + handleSubmit(e) { + e.preventDefault(); + + const element = $(ReactDOM.findDOMNode(this.refs.fileInput)); + if (element.prop('files').length === 0) { + return; + } + const file = element.prop('files')[0]; + + $('#upload-button').button('loading'); + + const formData = new FormData(); + formData.append('license', file, file.name); + + Client.uploadLicenseFile(formData, + () => { + Utils.clearFileInput(element[0]); + $('#upload-button').button('reset'); + this.setState({serverError: null}); + window.location.reload(true); + }, + (error) => { + Utils.clearFileInput(element[0]); + $('#upload-button').button('reset'); + this.setState({serverError: error.message}); + } + ); + } + + handleRemove(e) { + e.preventDefault(); + + $('#remove-button').button('loading'); + + Client.removeLicenseFile( + () => { + $('#remove-button').button('reset'); + this.setState({serverError: null}); + window.location.reload(true); + }, + (error) => { + $('#remove-button').button('reset'); + this.setState({serverError: error.message}); + } + ); + } + + render() { + var serverError = ''; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + var btnClass = 'btn'; + if (this.state.fileSelected) { + btnClass = 'btn btn-primary'; + } + + let edition; + let licenseType; + let licenseKey; + + if (global.window.mm_license.IsLicensed === 'true') { + edition = 'Mattermost Enterprise Edition. Designed for enterprise-scale communication.'; + licenseType = ( + <div> + <p> + {'This compiled release of Mattermost platform is provided under a '} + <a + href='http://mattermost.com' + target='_blank' + > + {'commercial license'} + </a> + {' from Mattermost, Inc. based on your subscription level and is subject to the '} + <a + href={global.window.mm_config.TermsOfServiceLink} + target='_blank' + > + {'Terms of Service.'} + </a> + </p> + <p>{'Your subscription details are as follows:'}</p> + {'Name: ' + global.window.mm_license.Name} + <br/> + {'Company or organization name: ' + global.window.mm_license.Company} + <br/> + {'Number of users: ' + global.window.mm_license.Users} + <br/> + {`License issued: ${Utils.displayDate(parseInt(global.window.mm_license.IssuedAt, 10))} ${Utils.displayTime(parseInt(global.window.mm_license.IssuedAt, 10), true)}`} + <br/> + {'Start date of license: ' + Utils.displayDate(parseInt(global.window.mm_license.StartsAt, 10))} + <br/> + {'Expiry date of license: ' + Utils.displayDate(parseInt(global.window.mm_license.ExpiresAt, 10))} + <br/> + {'LDAP: ' + global.window.mm_license.LDAP} + <br/> + </div> + ); + + licenseKey = ( + <div className='col-sm-8'> + <button + className='btn btn-danger' + onClick={this.handleRemove} + id='remove-button' + data-loading-text={'<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> Removing License...'} + > + {'Remove Enterprise License and Downgrade Server'} + </button> + <br/> + <br/> + <p className='help-text'> + {'If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start, '} + <a + href='http://mattermost.com' + target='_blank' + > + {'disable all Enterprise Edition features on this server'} + </a> + {'. This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.'} + </p> + </div> + ); + } else { + edition = 'Mattermost Team Edition. Designed for teams from 5 to 50 users.'; + + licenseType = ( + <span> + <p>{'This compiled release of Mattermost platform is offered under an MIT license.'}</p> + <p>{'See MIT-COMPILED-LICENSE.txt in your root install directory for details. See NOTICES.txt for information about open source software used in this system.'}</p> + </span> + ); + + licenseKey = ( + <div className='col-sm-8'> + <input + className='pull-left' + ref='fileInput' + type='file' + accept='.mattermost-license' + onChange={this.handleChange} + /> + <button + className={btnClass + ' pull-left'} + disabled={!this.state.fileSelected} + onClick={this.handleSubmit} + id='upload-button' + data-loading-text={'<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> Uploading License...'} + > + {'Upload'} + </button> + <br/> + <br/> + <br/> + {serverError} + <p className='help-text'> + {'Upload a license key for Mattermost Enterprise Edition to upgrade this server. '} + <a + href='http://mattermost.com' + target='_blank' + > + {'Visit us online'} + </a> + {' to learn more about the benefits of Enterprise Edition or to purchase a key.'} + </p> + </div> + ); + } + + return ( + <div className='wrapper--fixed'> + <h3>{'Edition and License'}</h3> + <form + className='form-horizontal' + role='form' + > + <div className='form-group'> + <label + className='control-label col-sm-4' + > + {'Edition: '} + </label> + <div className='col-sm-8'> + {edition} + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-4' + > + {'License: '} + </label> + <div className='col-sm-8'> + {licenseType} + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-4' + > + {'License Key: '} + </label> + {licenseKey} + </div> + </form> + </div> + ); + } +} diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx index 6337afabc..fef253c52 100644 --- a/web/react/components/file_upload.jsx +++ b/web/react/components/file_upload.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import * as client from '../utils/client.jsx'; +import * as Client from '../utils/client.jsx'; import Constants from '../utils/constants.jsx'; import ChannelStore from '../stores/channel_store.jsx'; import * as Utils from '../utils/utils.jsx'; @@ -26,7 +26,7 @@ export default class FileUpload extends React.Component { for (var j = 0; j < data.client_ids.length; j++) { delete requests[data.client_ids[j]]; } - this.setState({requests: requests}); + this.setState({requests}); } fileUploadFail(clientId, err) { @@ -52,7 +52,7 @@ export default class FileUpload extends React.Component { } // generate a unique id that can be used by other components to refer back to this upload - let clientId = Utils.generateId(); + const clientId = Utils.generateId(); // prepare data to be uploaded var formData = new FormData(); @@ -60,14 +60,14 @@ export default class FileUpload extends React.Component { formData.append('files', files[i], files[i].name); formData.append('client_ids', clientId); - var request = client.uploadFile(formData, + var request = Client.uploadFile(formData, this.fileUploadSuccess.bind(this, channelId), this.fileUploadFail.bind(this, clientId) ); var requests = this.state.requests; requests[clientId] = request; - this.setState({requests: requests}); + this.setState({requests}); this.props.onUploadStart([clientId], channelId); @@ -90,16 +90,7 @@ export default class FileUpload extends React.Component { this.uploadFiles(element.prop('files')); - // clear file input for all modern browsers - try { - element[0].value = ''; - if (element.value) { - element[0].type = 'text'; - element[0].type = 'file'; - } - } catch (e) { - // Do nothing - } + Utils.clearFileInput(element[0]); } handleDrop(e) { @@ -227,14 +218,14 @@ export default class FileUpload extends React.Component { formData.append('files', file, name); formData.append('client_ids', clientId); - var request = client.uploadFile(formData, + var request = Client.uploadFile(formData, self.fileUploadSuccess.bind(self, channelId), self.fileUploadFail.bind(self, clientId) ); var requests = self.state.requests; requests[clientId] = request; - self.setState({requests: requests}); + self.setState({requests}); self.props.onUploadStart([clientId], channelId); } @@ -263,7 +254,7 @@ export default class FileUpload extends React.Component { request.abort(); delete requests[clientId]; - this.setState({requests: requests}); + this.setState({requests}); } } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 96d1ef720..d60fea872 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1392,3 +1392,38 @@ export function regenOutgoingHookToken(data, success, error) { } }); } + +export function uploadLicenseFile(formData, success, error) { + $.ajax({ + url: '/api/v1/license/add', + type: 'POST', + data: formData, + cache: false, + contentType: false, + processData: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('uploadLicenseFile', xhr, status, err); + error(e); + } + }); + + track('api', 'api_license_upload'); +} + +export function removeLicenseFile(success, error) { + $.ajax({ + url: '/api/v1/license/remove', + type: 'POST', + cache: false, + contentType: false, + processData: false, + success, + error: function onError(xhr, status, err) { + var e = handleError('removeLicenseFile', xhr, status, err); + error(e); + } + }); + + track('api', 'api_license_upload'); +} diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 31bb2ba0b..24042321f 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -201,11 +201,21 @@ export function displayDate(ticks) { return monthNames[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); } -export function displayTime(ticks) { +export function displayTime(ticks, utc) { const d = new Date(ticks); - let hours = d.getHours(); - let minutes = d.getMinutes(); + let hours; + let minutes; let ampm = ''; + let timezone = ''; + + if (utc) { + hours = d.getUTCHours(); + minutes = d.getUTCMinutes(); + timezone = ' UTC'; + } else { + hours = d.getHours(); + minutes = d.getMinutes(); + } if (minutes <= 9) { minutes = '0' + minutes; @@ -224,7 +234,7 @@ export function displayTime(ticks) { } } - return hours + ':' + minutes + ampm; + return hours + ':' + minutes + ampm + timezone; } export function displayDateTime(ticks) { @@ -1301,3 +1311,16 @@ export function fillArray(value, length) { export function isFileTransfer(files) { return files.types != null && (files.types.indexOf ? files.types.indexOf('Files') !== -1 : files.types.contains('application/x-moz-file')); } + +export function clearFileInput(elm) { + // clear file input for all modern browsers + try { + elm.value = ''; + if (elm.value) { + elm.type = 'text'; + elm.type = 'file'; + } + } catch (e) { + // Do nothing + } +} diff --git a/web/sass-files/sass/partials/_admin-console.scss b/web/sass-files/sass/partials/_admin-console.scss index abba9de02..b28c7d984 100644 --- a/web/sass-files/sass/partials/_admin-console.scss +++ b/web/sass-files/sass/partials/_admin-console.scss @@ -174,6 +174,9 @@ .banner__content { width: 80%; } + &.warning { + background: #e60000; + } } .popover { border-radius: 3px; @@ -223,4 +226,4 @@ } } } -}
\ No newline at end of file +} diff --git a/web/templates/head.html b/web/templates/head.html index 08d8726ea..689c69d3c 100644 --- a/web/templates/head.html +++ b/web/templates/head.html @@ -46,6 +46,7 @@ <script> window.mm_config = {{ .ClientCfg }}; + window.mm_license = {{ .ClientLicense }}; window.mm_team = {{ .Team }}; window.mm_user = {{ .User }}; window.mm_channel = {{ .Channel }}; diff --git a/web/web.go b/web/web.go index 634a9d851..016e0c147 100644 --- a/web/web.go +++ b/web/web.go @@ -32,7 +32,7 @@ func NewHtmlTemplatePage(templateName string, title string) *HtmlTemplatePage { props := make(map[string]string) props["Title"] = title - return &HtmlTemplatePage{TemplateName: templateName, Props: props, ClientCfg: utils.ClientCfg} + return &HtmlTemplatePage{TemplateName: templateName, Props: props, ClientCfg: utils.ClientCfg, ClientLicense: utils.ClientLicense} } func (me *HtmlTemplatePage) Render(c *api.Context, w http.ResponseWriter) { |