summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJoram Wilander <jwawilander@gmail.com>2016-04-11 13:45:03 -0400
committerChristopher Speller <crspeller@gmail.com>2016-04-11 13:45:03 -0400
commit49ab8b216191749bd39694d79f687a84ad24adf0 (patch)
tree27b4966d437b15ddcd8d420b8a1afc1881455c13
parent5b96ad59c502d435dbca95950c4590a575b2c5b9 (diff)
downloadchat-49ab8b216191749bd39694d79f687a84ad24adf0.tar.gz
chat-49ab8b216191749bd39694d79f687a84ad24adf0.tar.bz2
chat-49ab8b216191749bd39694d79f687a84ad24adf0.zip
Add custom branding functionality (#2667)
-rw-r--r--api/admin.go76
-rw-r--r--api/file.go34
-rw-r--r--api/user.go16
-rw-r--r--config/config.json4
-rw-r--r--einterfaces/brand.go24
-rw-r--r--i18n/en.json60
-rw-r--r--model/config.go12
-rw-r--r--model/license.go16
-rw-r--r--store/sql_system_store.go21
-rw-r--r--store/sql_system_store_test.go6
-rw-r--r--store/store.go1
-rw-r--r--utils/config.go2
-rw-r--r--utils/license.go1
-rw-r--r--webapp/components/admin_console/team_settings.jsx297
-rw-r--r--webapp/components/login/login.jsx66
-rw-r--r--webapp/i18n/en.json11
-rw-r--r--webapp/sass/components/_inputs.scss4
-rw-r--r--webapp/sass/responsive/_mobile.scss13
-rw-r--r--webapp/sass/responsive/_tablet.scss11
-rw-r--r--webapp/sass/routes/_admin-console.scss5
-rw-r--r--webapp/sass/routes/_signup.scss21
-rw-r--r--webapp/stores/admin_store.jsx20
-rw-r--r--webapp/utils/async_client.jsx1
-rw-r--r--webapp/utils/client.jsx19
24 files changed, 654 insertions, 87 deletions
diff --git a/api/admin.go b/api/admin.go
index 7b041619e..3ed2bee7a 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -35,6 +35,8 @@ func InitAdmin(r *mux.Router) {
sr.Handle("/save_compliance_report", ApiUserRequired(saveComplianceReport)).Methods("POST")
sr.Handle("/compliance_reports", ApiUserRequired(getComplianceReports)).Methods("GET")
sr.Handle("/download_compliance_report/{id:[A-Za-z0-9]+}", ApiUserRequired(downloadComplianceReport)).Methods("GET")
+ sr.Handle("/upload_brand_image", ApiAdminSystemRequired(uploadBrandImage)).Methods("POST")
+ sr.Handle("/get_brand_image", ApiAppHandlerTrustRequester(getBrandImage)).Methods("GET")
}
func getLogs(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -422,3 +424,77 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+
+func uploadBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
+ if len(utils.Cfg.FileSettings.DriverName) == 0 {
+ c.Err = model.NewLocAppError("uploadBrandImage", "api.admin.upload_brand_image.storage.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ if r.ContentLength > model.MAX_FILE_SIZE {
+ c.Err = model.NewLocAppError("uploadBrandImage", "api.admin.upload_brand_image.too_large.app_error", nil, "")
+ c.Err.StatusCode = http.StatusRequestEntityTooLarge
+ return
+ }
+
+ if err := r.ParseMultipartForm(model.MAX_FILE_SIZE); err != nil {
+ c.Err = model.NewLocAppError("uploadBrandImage", "api.admin.upload_brand_image.parse.app_error", nil, "")
+ return
+ }
+
+ m := r.MultipartForm
+
+ imageArray, ok := m.File["image"]
+ if !ok {
+ c.Err = model.NewLocAppError("uploadBrandImage", "api.admin.upload_brand_image.no_file.app_error", nil, "")
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
+
+ if len(imageArray) <= 0 {
+ c.Err = model.NewLocAppError("uploadBrandImage", "api.admin.upload_brand_image.array.app_error", nil, "")
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
+
+ brandInterface := einterfaces.GetBrandInterface()
+ if brandInterface == nil {
+ c.Err = model.NewLocAppError("uploadBrandImage", "api.admin.upload_brand_image.not_available.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ if err := brandInterface.SaveBrandImage(imageArray[0]); err != nil {
+ c.Err = err
+ return
+ }
+
+ c.LogAudit("")
+
+ rdata := map[string]string{}
+ rdata["status"] = "OK"
+ w.Write([]byte(model.MapToJson(rdata)))
+}
+
+func getBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
+ if len(utils.Cfg.FileSettings.DriverName) == 0 {
+ c.Err = model.NewLocAppError("getBrandImage", "api.admin.get_brand_image.storage.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ brandInterface := einterfaces.GetBrandInterface()
+ if brandInterface == nil {
+ c.Err = model.NewLocAppError("getBrandImage", "api.admin.get_brand_image.not_available.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ if img, err := brandInterface.GetBrandImage(); err != nil {
+ w.Write(nil)
+ } else {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(img)
+ }
+}
diff --git a/api/file.go b/api/file.go
index ee9703455..991516bed 100644
--- a/api/file.go
+++ b/api/file.go
@@ -149,7 +149,7 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
- if err := writeFile(buf.Bytes(), path); err != nil {
+ if err := WriteFile(buf.Bytes(), path); err != nil {
c.Err = err
return
}
@@ -237,7 +237,7 @@ func handleImagesAndForget(filenames []string, fileData [][]byte, teamId, channe
return
}
- if err := writeFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil {
+ if err := WriteFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), channelId, userId, filename, err)
return
}
@@ -260,7 +260,7 @@ func handleImagesAndForget(filenames []string, fileData [][]byte, teamId, channe
return
}
- if err := writeFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil {
+ if err := WriteFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), channelId, userId, filename, err)
return
}
@@ -440,7 +440,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
func getFileAndForget(path string, fileData chan []byte) {
go func() {
- data, getErr := readFile(path)
+ data, getErr := ReadFile(path)
if getErr != nil {
l4g.Error(getErr)
fileData <- nil
@@ -506,7 +506,7 @@ func getExport(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err.StatusCode = http.StatusForbidden
return
}
- data, err := readFile(EXPORT_PATH + EXPORT_FILENAME)
+ data, err := ReadFile(EXPORT_PATH + EXPORT_FILENAME)
if err != nil {
c.Err = model.NewLocAppError("getExport", "api.file.get_export.retrieve.app_error", nil, err.Error())
return
@@ -517,7 +517,7 @@ func getExport(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write(data)
}
-func writeFile(f []byte, path string) *model.AppError {
+func WriteFile(f []byte, path string) *model.AppError {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth
@@ -540,14 +540,14 @@ func writeFile(f []byte, path string) *model.AppError {
}
if err != nil {
- return model.NewLocAppError("writeFile", "api.file.write_file.s3.app_error", nil, err.Error())
+ return model.NewLocAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error())
}
} else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
- if err := writeFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil {
+ if err := WriteFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil {
return err
}
} else {
- return model.NewLocAppError("writeFile", "api.file.write_file.configured.app_error", nil, "")
+ return model.NewLocAppError("WriteFile", "api.file.write_file.configured.app_error", nil, "")
}
return nil
@@ -574,7 +574,7 @@ func moveFile(oldPath, newPath string) *model.AppError {
return model.NewLocAppError("moveFile", "api.file.move_file.delete_from_s3.app_error", nil, err.Error())
}
- if err := writeFile(fileBytes, newPath); err != nil {
+ if err := WriteFile(fileBytes, newPath); err != nil {
return err
}
} else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
@@ -588,19 +588,19 @@ func moveFile(oldPath, newPath string) *model.AppError {
return nil
}
-func writeFileLocally(f []byte, path string) *model.AppError {
+func WriteFileLocally(f []byte, path string) *model.AppError {
if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil {
- return model.NewLocAppError("writeFile", "api.file.write_file_locally.create_dir.app_error", nil, err.Error())
+ return model.NewLocAppError("WriteFile", "api.file.write_file_locally.create_dir.app_error", nil, err.Error())
}
if err := ioutil.WriteFile(path, f, 0644); err != nil {
- return model.NewLocAppError("writeFile", "api.file.write_file_locally.writing.app_error", nil, err.Error())
+ return model.NewLocAppError("WriteFile", "api.file.write_file_locally.writing.app_error", nil, err.Error())
}
return nil
}
-func readFile(path string) ([]byte, *model.AppError) {
+func ReadFile(path string) ([]byte, *model.AppError) {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth
@@ -620,18 +620,18 @@ func readFile(path string) ([]byte, *model.AppError) {
if f != nil {
return f, nil
} else if tries >= 3 {
- return nil, model.NewLocAppError("readFile", "api.file.read_file.get.app_error", nil, "path="+path+", err="+err.Error())
+ return nil, model.NewLocAppError("ReadFile", "api.file.read_file.get.app_error", nil, "path="+path+", err="+err.Error())
}
time.Sleep(3000 * time.Millisecond)
}
} else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
if f, err := ioutil.ReadFile(utils.Cfg.FileSettings.Directory + path); err != nil {
- return nil, model.NewLocAppError("readFile", "api.file.read_file.reading_local.app_error", nil, err.Error())
+ return nil, model.NewLocAppError("ReadFile", "api.file.read_file.reading_local.app_error", nil, err.Error())
} else {
return f, nil
}
} else {
- return nil, model.NewLocAppError("readFile", "api.file.read_file.configured.app_error", nil, "")
+ return nil, model.NewLocAppError("ReadFile", "api.file.read_file.configured.app_error", nil, "")
}
}
diff --git a/api/user.go b/api/user.go
index 76eeaa441..08d096c51 100644
--- a/api/user.go
+++ b/api/user.go
@@ -54,7 +54,7 @@ func InitUser(r *mux.Router) {
sr.Handle("/verify_email", ApiAppHandler(verifyEmail)).Methods("POST")
sr.Handle("/resend_verification", ApiAppHandler(resendVerification)).Methods("POST")
sr.Handle("/mfa", ApiAppHandler(checkMfa)).Methods("POST")
- sr.Handle("/generate_mfa_qr", ApiUserRequired(generateMfaQrCode)).Methods("GET")
+ sr.Handle("/generate_mfa_qr", ApiUserRequiredTrustRequester(generateMfaQrCode)).Methods("GET")
sr.Handle("/update_mfa", ApiUserRequired(updateMfa)).Methods("POST")
sr.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST")
@@ -1150,14 +1150,14 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
path := "teams/" + c.Session.TeamId + "/users/" + id + "/profile.png"
- if data, err := readFile(path); err != nil {
+ if data, err := ReadFile(path); err != nil {
if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil {
c.Err = err
return
}
- if err := writeFile(img, path); err != nil {
+ if err := WriteFile(img, path); err != nil {
c.Err = err
return
}
@@ -1185,7 +1185,13 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if err := r.ParseMultipartForm(10000000); err != nil {
+ if r.ContentLength > model.MAX_FILE_SIZE {
+ c.Err = model.NewLocAppError("uploadProfileImage", "api.user.upload_profile_user.too_large.app_error", nil, "")
+ c.Err.StatusCode = http.StatusRequestEntityTooLarge
+ return
+ }
+
+ if err := r.ParseMultipartForm(model.MAX_FILE_SIZE); err != nil {
c.Err = model.NewLocAppError("uploadProfileImage", "api.user.upload_profile_user.parse.app_error", nil, "")
return
}
@@ -1245,7 +1251,7 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
path := "teams/" + c.Session.TeamId + "/users/" + c.Session.UserId + "/profile.png"
- if err := writeFile(buf.Bytes(), path); err != nil {
+ if err := WriteFile(buf.Bytes(), path); err != nil {
c.Err = model.NewLocAppError("uploadProfileImage", "api.user.upload_profile_user.upload_profile.app_error", nil, "")
return
}
diff --git a/config/config.json b/config/config.json
index 11627df70..5b05158b5 100644
--- a/config/config.json
+++ b/config/config.json
@@ -32,7 +32,9 @@
"EnableUserCreation": true,
"RestrictCreationToDomains": "",
"RestrictTeamNames": true,
- "EnableTeamListing": false
+ "EnableTeamListing": false,
+ "EnableCustomBrand": false,
+ "CustomBrandText": ""
},
"SqlSettings": {
"DriverName": "mysql",
diff --git a/einterfaces/brand.go b/einterfaces/brand.go
new file mode 100644
index 000000000..7c15659bb
--- /dev/null
+++ b/einterfaces/brand.go
@@ -0,0 +1,24 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package einterfaces
+
+import (
+ "github.com/mattermost/platform/model"
+ "mime/multipart"
+)
+
+type BrandInterface interface {
+ SaveBrandImage(*multipart.FileHeader) *model.AppError
+ GetBrandImage() ([]byte, *model.AppError)
+}
+
+var theBrandInterface BrandInterface
+
+func RegisterBrandInterface(newInterface BrandInterface) {
+ theBrandInterface = newInterface
+}
+
+func GetBrandInterface() BrandInterface {
+ return theBrandInterface
+}
diff --git a/i18n/en.json b/i18n/en.json
index c730f5711..27f9a680a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -48,6 +48,66 @@
"translation": "September"
},
{
+ "id": "api.admin.upload_brand_image.storage.app_error",
+ "translation": "Unable to upload image. Image storage is not configured."
+ },
+ {
+ "id": "api.admin.upload_brand_image.too_large.app_error",
+ "translation": "Unable to upload file. File is too large."
+ },
+ {
+ "id": "api.admin.upload_brand_image.parse.app_error",
+ "translation": "Could not parse multipart form"
+ },
+ {
+ "id": "api.admin.upload_brand_image.no_file.app_error",
+ "translation": "No file under 'image' in request"
+ },
+ {
+ "id": "api.admin.upload_brand_image.array.app_error",
+ "translation": "Empty array under 'image' in request"
+ },
+ {
+ "id": "api.admin.upload_brand_image.not_available.app_error",
+ "translation": "Custom branding is not configured or supported on this server"
+ },
+ {
+ "id": "api.admin.get_brand_image.storage.app_error",
+ "translation": "Image storage is not configured."
+ },
+ {
+ "id": "api.admin.get_brand_image.not_available.app_error",
+ "translation": "Custom branding is not configured or supported on this server"
+ },
+ {
+ "id": "store.sql_system.get_by_name.app_error",
+ "translation": "We couldn't find the system variable."
+ },
+ {
+ "id": "ent.brand.save_brand_image.open.app_error",
+ "translation": "Unable to open the image."
+ },
+ {
+ "id": "ent.brand.save_brand_image.decode_config.app_error",
+ "translation": "Unable to decode image config."
+ },
+ {
+ "id": "ent.brand.save_brand_image.too_large.app_error",
+ "translation": "Unable to open image. Image is too large."
+ },
+ {
+ "id": "ent.brand.save_brand_image.decode.app_error",
+ "translation": "Unable to decode image."
+ },
+ {
+ "id": "ent.brand.save_brand_image.encode.app_error",
+ "translation": "Unable to encode image as PNG."
+ },
+ {
+ "id": "ent.brand.save_brand_image.save_image.app_error",
+ "translation": "Unable to save image"
+ },
+ {
"id": "api.admin.file_read_error",
"translation": "Error reading log file"
},
diff --git a/model/config.go b/model/config.go
index a8974359d..26c71d07f 100644
--- a/model/config.go
+++ b/model/config.go
@@ -158,6 +158,8 @@ type TeamSettings struct {
RestrictCreationToDomains string
RestrictTeamNames *bool
EnableTeamListing *bool
+ EnableCustomBrand *bool
+ CustomBrandText *string
}
type LdapSettings struct {
@@ -296,6 +298,16 @@ func (o *Config) SetDefaults() {
*o.TeamSettings.EnableTeamListing = false
}
+ if o.TeamSettings.EnableCustomBrand == nil {
+ o.TeamSettings.EnableCustomBrand = new(bool)
+ *o.TeamSettings.EnableCustomBrand = false
+ }
+
+ if o.TeamSettings.CustomBrandText == nil {
+ o.TeamSettings.CustomBrandText = new(string)
+ *o.TeamSettings.CustomBrandText = ""
+ }
+
if o.EmailSettings.EnableSignInWithEmail == nil {
o.EmailSettings.EnableSignInWithEmail = new(bool)
diff --git a/model/license.go b/model/license.go
index cab22a685..0cea67c3d 100644
--- a/model/license.go
+++ b/model/license.go
@@ -32,11 +32,12 @@ type Customer struct {
}
type Features struct {
- Users *int `json:"users"`
- LDAP *bool `json:"ldap"`
- MFA *bool `json:"mfa"`
- GoogleSSO *bool `json:"google_sso"`
- Compliance *bool `json:"compliance"`
+ Users *int `json:"users"`
+ LDAP *bool `json:"ldap"`
+ MFA *bool `json:"mfa"`
+ GoogleSSO *bool `json:"google_sso"`
+ Compliance *bool `json:"compliance"`
+ CustomBrand *bool `json:"custom_brand"`
}
func (f *Features) SetDefaults() {
@@ -64,6 +65,11 @@ func (f *Features) SetDefaults() {
f.Compliance = new(bool)
*f.Compliance = true
}
+
+ if f.CustomBrand == nil {
+ f.CustomBrand = new(bool)
+ *f.CustomBrand = true
+ }
}
func (l *License) IsExpired() bool {
diff --git a/store/sql_system_store.go b/store/sql_system_store.go
index f8da06cec..a2b4f6396 100644
--- a/store/sql_system_store.go
+++ b/store/sql_system_store.go
@@ -114,3 +114,24 @@ func (s SqlSystemStore) Get() StoreChannel {
return storeChannel
}
+
+func (s SqlSystemStore) GetByName(name string) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var system model.System
+ if err := s.GetReplica().SelectOne(&system, "SELECT * FROM Systems WHERE Name = :Name", map[string]interface{}{"Name": name}); err != nil {
+ result.Err = model.NewLocAppError("SqlSystemStore.GetByName", "store.sql_system.get_by_name.app_error", nil, "")
+ }
+
+ result.Data = &system
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_system_store_test.go b/store/sql_system_store_test.go
index ce149e97a..74e2876ad 100644
--- a/store/sql_system_store_test.go
+++ b/store/sql_system_store_test.go
@@ -30,6 +30,12 @@ func TestSqlSystemStore(t *testing.T) {
if systems2[system.Name] != system.Value {
t.Fatal()
}
+
+ result3 := <-store.System().GetByName(system.Name)
+ rsystem := result3.Data.(*model.System)
+ if rsystem.Value != system.Value {
+ t.Fatal()
+ }
}
func TestSqlSystemStoreSaveOrUpdate(t *testing.T) {
diff --git a/store/store.go b/store/store.go
index 323595ffb..4a4fa1481 100644
--- a/store/store.go
+++ b/store/store.go
@@ -184,6 +184,7 @@ type SystemStore interface {
SaveOrUpdate(system *model.System) StoreChannel
Update(system *model.System) StoreChannel
Get() StoreChannel
+ GetByName(name string) StoreChannel
}
type WebhookStore interface {
diff --git a/utils/config.go b/utils/config.go
index d8f52ce49..244ff7180 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -201,6 +201,8 @@ func getClientConfig(c *model.Config) map[string]string {
props["EnableUserCreation"] = strconv.FormatBool(c.TeamSettings.EnableUserCreation)
props["RestrictTeamNames"] = strconv.FormatBool(*c.TeamSettings.RestrictTeamNames)
props["EnableTeamListing"] = strconv.FormatBool(*c.TeamSettings.EnableTeamListing)
+ props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand)
+ props["CustomBrandText"] = *c.TeamSettings.CustomBrandText
props["EnableOAuthServiceProvider"] = strconv.FormatBool(c.ServiceSettings.EnableOAuthServiceProvider)
diff --git a/utils/license.go b/utils/license.go
index 217fd27ce..fcc08e6b1 100644
--- a/utils/license.go
+++ b/utils/license.go
@@ -117,6 +117,7 @@ func getClientLicense(l *model.License) map[string]string {
props["MFA"] = strconv.FormatBool(*l.Features.MFA)
props["GoogleSSO"] = strconv.FormatBool(*l.Features.GoogleSSO)
props["Compliance"] = strconv.FormatBool(*l.Features.Compliance)
+ props["CustomBrand"] = strconv.FormatBool(*l.Features.CustomBrand)
props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10)
props["StartsAt"] = strconv.FormatInt(l.StartsAt, 10)
props["ExpiresAt"] = strconv.FormatInt(l.ExpiresAt, 10)
diff --git a/webapp/components/admin_console/team_settings.jsx b/webapp/components/admin_console/team_settings.jsx
index 654f0085d..d361c989f 100644
--- a/webapp/components/admin_console/team_settings.jsx
+++ b/webapp/components/admin_console/team_settings.jsx
@@ -2,11 +2,11 @@
// See License.txt for license information.
import $ from 'jquery';
-import ReactDOM from 'react-dom';
import * as Client from 'utils/client.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
+import * as Utils from 'utils/utils.jsx';
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl';
+import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl';
const holders = defineMessages({
siteNameExample: {
@@ -29,42 +29,91 @@ const holders = defineMessages({
import React from 'react';
+const ENABLE_BRAND_ACTION = 'enable_brand_action';
+const DISABLE_BRAND_ACTION = 'disable_brand_action';
+
class TeamSettings extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleImageChange = this.handleImageChange.bind(this);
+ this.handleImageSubmit = this.handleImageSubmit.bind(this);
+
+ this.uploading = false;
this.state = {
saveNeeded: false,
+ brandImageExists: false,
+ enableCustomBrand: this.props.config.TeamSettings.EnableCustomBrand,
serverError: null
};
}
- handleChange() {
- var s = {saveNeeded: true, serverError: this.state.serverError};
+ componentWillMount() {
+ if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.CustomBrand === 'true') {
+ $.get('/api/v1/admin/get_brand_image').done(() => this.setState({brandImageExists: true}));
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.refs.image) {
+ const reader = new FileReader();
+
+ const img = this.refs.image;
+ reader.onload = (e) => {
+ $(img).attr('src', e.target.result);
+ };
+
+ reader.readAsDataURL(this.state.brandImage);
+ }
+ }
+
+ handleChange(action) {
+ var s = {saveNeeded: true};
+
+ if (action === ENABLE_BRAND_ACTION) {
+ s.enableCustomBrand = true;
+ }
+
+ if (action === DISABLE_BRAND_ACTION) {
+ s.enableCustomBrand = false;
+ }
+
this.setState(s);
}
+ handleImageChange() {
+ const element = $(this.refs.fileInput);
+ if (element.prop('files').length > 0) {
+ this.setState({fileSelected: true, brandImage: element.prop('files')[0]});
+ }
+ }
+
handleSubmit(e) {
e.preventDefault();
$('#save-button').button('loading');
var config = this.props.config;
- config.TeamSettings.SiteName = ReactDOM.findDOMNode(this.refs.SiteName).value.trim();
- config.TeamSettings.RestrictCreationToDomains = ReactDOM.findDOMNode(this.refs.RestrictCreationToDomains).value.trim();
- config.TeamSettings.EnableTeamCreation = ReactDOM.findDOMNode(this.refs.EnableTeamCreation).checked;
- config.TeamSettings.EnableUserCreation = ReactDOM.findDOMNode(this.refs.EnableUserCreation).checked;
- config.TeamSettings.RestrictTeamNames = ReactDOM.findDOMNode(this.refs.RestrictTeamNames).checked;
- config.TeamSettings.EnableTeamListing = ReactDOM.findDOMNode(this.refs.EnableTeamListing).checked;
+ config.TeamSettings.SiteName = this.refs.SiteName.value.trim();
+ config.TeamSettings.RestrictCreationToDomains = this.refs.RestrictCreationToDomains.value.trim();
+ config.TeamSettings.EnableTeamCreation = this.refs.EnableTeamCreation.checked;
+ config.TeamSettings.EnableUserCreation = this.refs.EnableUserCreation.checked;
+ config.TeamSettings.RestrictTeamNames = this.refs.RestrictTeamNames.checked;
+ config.TeamSettings.EnableTeamListing = this.refs.EnableTeamListing.checked;
+ config.TeamSettings.EnableCustomBrand = this.refs.EnableCustomBrand.checked;
+
+ if (this.refs.CustomBrandText) {
+ config.TeamSettings.CustomBrandText = this.refs.CustomBrandText.value;
+ }
var MaxUsersPerTeam = 50;
- if (!isNaN(parseInt(ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value, 10))) {
- MaxUsersPerTeam = parseInt(ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value, 10);
+ if (!isNaN(parseInt(this.refs.MaxUsersPerTeam.value, 10))) {
+ MaxUsersPerTeam = parseInt(this.refs.MaxUsersPerTeam.value, 10);
}
config.TeamSettings.MaxUsersPerTeam = MaxUsersPerTeam;
- ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value = MaxUsersPerTeam;
+ this.refs.MaxUsersPerTeam.value = MaxUsersPerTeam;
Client.saveConfig(
config,
@@ -86,6 +135,219 @@ class TeamSettings extends React.Component {
);
}
+ handleImageSubmit(e) {
+ e.preventDefault();
+
+ if (!this.state.brandImage) {
+ return;
+ }
+
+ if (this.uploading) {
+ return;
+ }
+
+ $('#upload-button').button('loading');
+ this.uploading = true;
+
+ Client.uploadBrandImage(this.state.brandImage,
+ () => {
+ $('#upload-button').button('complete');
+ this.setState({brandImageExists: true, brandImage: null});
+ this.uploading = false;
+ },
+ (err) => {
+ $('#upload-button').button('reset');
+ this.uploading = false;
+ this.setState({serverImageError: err.message});
+ }
+ );
+ }
+
+ createBrandSettings() {
+ var btnClass = 'btn';
+ if (this.state.fileSelected) {
+ btnClass = 'btn btn-primary';
+ }
+
+ var serverImageError = '';
+ if (this.state.serverImageError) {
+ serverImageError = <div className='form-group has-error'><label className='control-label'>{this.state.serverImageError}</label></div>;
+ }
+
+ let uploadImage;
+ let uploadText;
+ if (this.state.enableCustomBrand) {
+ let img;
+ if (this.state.brandImage) {
+ img = (
+ <img
+ ref='image'
+ className='brand-img'
+ src=''
+ />
+ );
+ } else if (this.state.brandImageExists) {
+ img = (
+ <img
+ className='brand-img'
+ src='/api/v1/admin/get_brand_image'
+ />
+ );
+ } else {
+ img = (
+ <p>
+ <FormattedMessage
+ id='admin.team.noBrandImage'
+ defaultMessage='No brand image uploaded'
+ />
+ </p>
+ );
+ }
+
+ uploadImage = (
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='CustomBrandImage'
+ >
+ <FormattedMessage
+ id='admin.team.brandImageTitle'
+ defaultMessage='Custom Brand Image:'
+ />
+ </label>
+ <div className='col-sm-8'>
+ {img}
+ </div>
+ <div className='col-sm-4'/>
+ <div className='col-sm-8'>
+ <div className='file__upload'>
+ <button className='btn btn-default'>
+ <FormattedMessage
+ id='admin.team.chooseImage'
+ defaultMessage='Choose New Image'
+ />
+ </button>
+ <input
+ ref='fileInput'
+ type='file'
+ accept='.jpg,.png,.bmp'
+ onChange={this.handleImageChange}
+ />
+ </div>
+ <button
+ className={btnClass}
+ disabled={!this.state.fileSelected}
+ onClick={this.handleImageSubmit}
+ id='upload-button'
+ data-loading-text={'<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> ' + Utils.localizeMessage('admin.team.uploading', 'Uploading..')}
+ data-complete-text={'<span class=\'glyphicon glyphicon-ok\'></span> ' + Utils.localizeMessage('admin.team.uploaded', 'Uploaded!')}
+ >
+ <FormattedMessage
+ id='admin.team.upload'
+ defaultMessage='Upload'
+ />
+ </button>
+ <br/>
+ {serverImageError}
+ <p className='help-text no-margin'>
+ <FormattedHTMLMessage
+ id='admin.team.uploadDesc'
+ defaultMessage='Customize your user experience by adding a custom image to your login screen. See examples at <a href="http://docs.mattermost.com/administration/config-settings.html#custom-branding" target="_blank">docs.mattermost.com/administration/config-settings.html#custom-branding</a>.'
+ />
+ </p>
+ </div>
+ </div>
+ );
+
+ uploadText = (
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='CustomBrandText'
+ >
+ <FormattedMessage
+ id='admin.team.brandTextTitle'
+ defaultMessage='Custom Brand Text:'
+ />
+ </label>
+ <div className='col-sm-8'>
+ <textarea
+ type='text'
+ rows='5'
+ maxLength='1024'
+ className='form-control admin-textarea'
+ id='CustomBrandText'
+ ref='CustomBrandText'
+ onChange={this.handleChange}
+ >
+ {this.props.config.TeamSettings.CustomBrandText}
+ </textarea>
+ <p className='help-text'>
+ <FormattedMessage
+ id='admin.team.brandTextDescription'
+ defaultMessage='The custom branding Markdown-formatted text you would like to appear below your custom brand image on your login sreen.'
+ />
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='EnableCustomBrand'
+ >
+ <FormattedMessage
+ id='admin.team.brandTitle'
+ defaultMessage='Enable Custom Branding: '
+ />
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableCustomBrand'
+ value='true'
+ ref='EnableCustomBrand'
+ defaultChecked={this.props.config.TeamSettings.EnableCustomBrand}
+ onChange={this.handleChange.bind(this, ENABLE_BRAND_ACTION)}
+ />
+ <FormattedMessage
+ id='admin.team.true'
+ defaultMessage='true'
+ />
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableCustomBrand'
+ value='false'
+ defaultChecked={!this.props.config.TeamSettings.EnableCustomBrand}
+ onChange={this.handleChange.bind(this, DISABLE_BRAND_ACTION)}
+ />
+ <FormattedMessage
+ id='admin.team.false'
+ defaultMessage='false'
+ />
+ </label>
+ <p className='help-text'>
+ <FormattedMessage
+ id='admin.team.brandDesc'
+ defaultMessage='Enable custom branding to show an image of your choice, uploaded below, and some help text, written below, on the login page.'
+ />
+ </p>
+ </div>
+ </div>
+
+ {uploadImage}
+ {uploadText}
+ </div>
+ );
+ }
+
render() {
const {formatMessage} = this.props.intl;
var serverError = '';
@@ -98,6 +360,11 @@ class TeamSettings extends React.Component {
saveClass = 'btn btn-primary';
}
+ let brand;
+ if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.CustomBrand === 'true') {
+ brand = this.createBrandSettings();
+ }
+
return (
<div className='wrapper--fixed'>
@@ -387,6 +654,8 @@ class TeamSettings extends React.Component {
</div>
</div>
+ {brand}
+
<div className='form-group'>
<div className='col-sm-12'>
{serverError}
@@ -417,4 +686,4 @@ TeamSettings.propTypes = {
config: React.PropTypes.object
};
-export default injectIntl(TeamSettings); \ No newline at end of file
+export default injectIntl(TeamSettings);
diff --git a/webapp/components/login/login.jsx b/webapp/components/login/login.jsx
index ed7495b13..a3dadbf36 100644
--- a/webapp/components/login/login.jsx
+++ b/webapp/components/login/login.jsx
@@ -9,6 +9,7 @@ import LoginMfa from './components/login_mfa.jsx';
import TeamStore from 'stores/team_store.jsx';
import UserStore from 'stores/user_store.jsx';
+import * as TextFormatting from 'utils/text_formatting.jsx';
import * as Client from 'utils/client.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
@@ -134,6 +135,24 @@ export default class Login extends React.Component {
);
}
}
+ createCustomLogin() {
+ if (global.window.mm_license.IsLicensed === 'true' &&
+ global.window.mm_license.CustomBrand === 'true' &&
+ global.window.mm_config.EnableCustomBrand === 'true') {
+ const text = global.window.mm_config.CustomBrandText || '';
+
+ return (
+ <div>
+ <img
+ src='/api/v1/admin/get_brand_image'
+ />
+ <p dangerouslySetInnerHTML={{__html: TextFormatting.formatText(text)}}/>
+ </div>
+ );
+ }
+
+ return null;
+ }
createLoginOptions(currentTeam) {
const extraParam = Utils.getUrlParameter('extra');
let extraBox = '';
@@ -364,6 +383,8 @@ export default class Login extends React.Component {
}
let content;
+ let customContent;
+ let customClass;
if (this.state.showMfa) {
content = (
<LoginMfa
@@ -375,6 +396,10 @@ export default class Login extends React.Component {
);
} else {
content = this.createLoginOptions(currentTeam);
+ customContent = this.createCustomLogin();
+ if (customContent) {
+ customClass = 'branded';
+ }
}
return (
@@ -388,24 +413,29 @@ export default class Login extends React.Component {
</Link>
</div>
<div className='col-sm-12'>
- <div className='signup-team__container'>
- <h5 className='margin--less'>
- <FormattedMessage
- id='login.signTo'
- defaultMessage='Sign in to:'
- />
- </h5>
- <h2 className='signup-team__name'>{currentTeam.display_name}</h2>
- <h2 className='signup-team__subdomain'>
- <FormattedMessage
- id='login.on'
- defaultMessage='on {siteName}'
- values={{
- siteName: global.window.mm_config.SiteName
- }}
- />
- </h2>
- {content}
+ <div className={'signup-team__container ' + customClass}>
+ <div className='signup__markdown'>
+ {customContent}
+ </div>
+ <div className='signup__content'>
+ <h5 className='margin--less'>
+ <FormattedMessage
+ id='login.signTo'
+ defaultMessage='Sign in to:'
+ />
+ </h5>
+ <h2 className='signup-team__name'>{currentTeam.display_name}</h2>
+ <h2 className='signup-team__subdomain'>
+ <FormattedMessage
+ id='login.on'
+ defaultMessage='on {siteName}'
+ values={{
+ siteName: global.window.mm_config.SiteName
+ }}
+ />
+ </h2>
+ {content}
+ </div>
</div>
</div>
</div>
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 023584e1d..df6a09779 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -488,6 +488,17 @@
"admin.system_analytics.activeUsers": "Active Users With Posts",
"admin.system_analytics.title": "the System",
"admin.system_analytics.totalPosts": "Total Posts",
+ "admin.team.noBrandImage": "No brand image uploaded",
+ "admin.team.brandImageTitle": "Custom Brand Image:",
+ "admin.team.chooseImage": "Choose New Image",
+ "admin.team.uploading": "Uploading..",
+ "admin.team.uploaded": "Uploaded!",
+ "admin.team.upload": "Upload",
+ "admin.team.uploadDesc": "Customize your user experience by adding a custom image to your login screen. See examples at <a href='http://docs.mattermost.com/administration/config-settings.html#custom-branding' target='_blank'>docs.mattermost.com/administration/config-settings.html#custom-branding</a>.",
+ "admin.team.brandTextTitle": "Custom Brand Text:",
+ "admin.team.brandTextDescription": "The custom branding Markdown-formatted text you would like to appear below your custom brand image on your login sreen.",
+ "admin.team.brandTitle": "Enable Custom Branding: ",
+ "admin.team.brandDesc": "Enable custom branding to show an image of your choice, uploaded below, and some help text, written below, on the login page.",
"admin.team.dirDesc": "When true, teams that are configured to show in team directory will show on main page inplace of creating a new team.",
"admin.team.dirTitle": "Enable Team Directory: ",
"admin.team.false": "false",
diff --git a/webapp/sass/components/_inputs.scss b/webapp/sass/components/_inputs.scss
index 42ab56128..c34d0d2d4 100644
--- a/webapp/sass/components/_inputs.scss
+++ b/webapp/sass/components/_inputs.scss
@@ -33,3 +33,7 @@ fieldset {
}
}
}
+
+.admin-textarea {
+ resize: none;
+}
diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss
index e3fac21f7..21c3135c2 100644
--- a/webapp/sass/responsive/_mobile.scss
+++ b/webapp/sass/responsive/_mobile.scss
@@ -738,16 +738,17 @@
.inner-wrap {
@include single-transition(all, .5s, ease);
- &:before{
- content:"";
+
+ &:before {
//Some trickery in order for the z-index transition to happen immediately on move-in and delayed on move-out.
- transition: background-color 0.5s ease, z-index 0s ease 0.5s;
background-color: transparent;
+ content: '';
height: 100%;
- width: calc(100% + 30px);
left: -15px;
position: absolute;
top: 0;
+ transition: background-color 0.5s ease, z-index 0s ease 0.5s;
+ width: calc(100% + 30px);
z-index: 0;
}
@@ -755,9 +756,9 @@
@include translate3d(290px, 0, 0);
&:before {
+ background-color: rgba(0, 0, 0, .4);
+ transition: background-color .5s ease;
z-index: 9999;
- transition: background-color 0.5s ease;
- background-color: rgba(0, 0, 0, 0.4);
}
}
diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss
index db2a8d7b9..cb5216dea 100644
--- a/webapp/sass/responsive/_tablet.scss
+++ b/webapp/sass/responsive/_tablet.scss
@@ -1,6 +1,17 @@
@charset 'UTF-8';
@media screen and (max-width: 960px) {
+ .signup-team__container {
+ &.branded {
+ display: block;
+ margin: 0 auto;
+ max-width: 380px;
+
+ .signup__markdown {
+ display: none;
+ }
+ }
+ }
.sidebar--right {
@include single-transition(all, .5s, ease);
@include translateX(100%);
diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss
index faa66e08b..6987b59ae 100644
--- a/webapp/sass/routes/_admin-console.scss
+++ b/webapp/sass/routes/_admin-console.scss
@@ -344,3 +344,8 @@
}
}
}
+
+.brand-img {
+ margin-bottom: 1.5em;
+ max-width: 150px;
+}
diff --git a/webapp/sass/routes/_signup.scss b/webapp/sass/routes/_signup.scss
index 6d6092170..77ccdf4ed 100644
--- a/webapp/sass/routes/_signup.scss
+++ b/webapp/sass/routes/_signup.scss
@@ -10,12 +10,33 @@
margin-right: 5px;
}
}
+
.signup-team__container {
margin: 0 auto;
max-width: 380px;
padding: 100px 0 50px;
position: relative;
+ &.branded {
+ @include display-flex;
+ @include flex-direction(row);
+ max-width: 900px;
+
+ .signup__markdown {
+ @include flex(1.3 0 0);
+ padding-right: 80px;
+
+ p {
+ color: lighten($black, 50%);
+ }
+ }
+
+ .signup__content {
+ @include flex(1 0 0);
+ }
+
+ }
+
&.padding--less {
padding-top: 50px;
}
diff --git a/webapp/stores/admin_store.jsx b/webapp/stores/admin_store.jsx
index 0f19dd484..ecfbaf85f 100644
--- a/webapp/stores/admin_store.jsx
+++ b/webapp/stores/admin_store.jsx
@@ -24,26 +24,6 @@ class AdminStoreClass extends EventEmitter {
this.config = null;
this.teams = null;
this.complianceReports = null;
-
- this.emitLogChange = this.emitLogChange.bind(this);
- this.addLogChangeListener = this.addLogChangeListener.bind(this);
- this.removeLogChangeListener = this.removeLogChangeListener.bind(this);
-
- this.emitAuditChange = this.emitAuditChange.bind(this);
- this.addAuditChangeListener = this.addAuditChangeListener.bind(this);
- this.removeAuditChangeListener = this.removeAuditChangeListener.bind(this);
-
- this.emitComplianceReportsChange = this.emitComplianceReportsChange.bind(this);
- this.addComplianceReportsChangeListener = this.addComplianceReportsChangeListener.bind(this);
- this.removeComplianceReportsChangeListener = this.removeComplianceReportsChangeListener.bind(this);
-
- this.emitConfigChange = this.emitConfigChange.bind(this);
- this.addConfigChangeListener = this.addConfigChangeListener.bind(this);
- this.removeConfigChangeListener = this.removeConfigChangeListener.bind(this);
-
- this.emitAllTeamsChange = this.emitAllTeamsChange.bind(this);
- this.addAllTeamsChangeListener = this.addAllTeamsChangeListener.bind(this);
- this.removeAllTeamsChangeListener = this.removeAllTeamsChangeListener.bind(this);
}
emitLogChange() {
diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx
index 5b0c221ae..80a08dc21 100644
--- a/webapp/utils/async_client.jsx
+++ b/webapp/utils/async_client.jsx
@@ -1334,4 +1334,3 @@ export function regenCommandToken(id) {
}
);
}
-
diff --git a/webapp/utils/client.jsx b/webapp/utils/client.jsx
index 6c784c11c..687d47da4 100644
--- a/webapp/utils/client.jsx
+++ b/webapp/utils/client.jsx
@@ -1738,3 +1738,22 @@ export function updateMfa(data, success, error) {
}
});
}
+
+export function uploadBrandImage(image, success, error) {
+ const formData = new FormData();
+ formData.append('image', image, image.name);
+
+ $.ajax({
+ url: '/api/v1/admin/upload_brand_image',
+ type: 'POST',
+ data: formData,
+ cache: false,
+ contentType: false,
+ processData: false,
+ success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('uploadBrandImage', xhr, status, err);
+ error(e);
+ }
+ });
+}