diff options
-rw-r--r-- | api/admin.go | 76 | ||||
-rw-r--r-- | api/file.go | 34 | ||||
-rw-r--r-- | api/user.go | 16 | ||||
-rw-r--r-- | config/config.json | 4 | ||||
-rw-r--r-- | einterfaces/brand.go | 24 | ||||
-rw-r--r-- | i18n/en.json | 60 | ||||
-rw-r--r-- | model/config.go | 12 | ||||
-rw-r--r-- | model/license.go | 16 | ||||
-rw-r--r-- | store/sql_system_store.go | 21 | ||||
-rw-r--r-- | store/sql_system_store_test.go | 6 | ||||
-rw-r--r-- | store/store.go | 1 | ||||
-rw-r--r-- | utils/config.go | 2 | ||||
-rw-r--r-- | utils/license.go | 1 | ||||
-rw-r--r-- | webapp/components/admin_console/team_settings.jsx | 297 | ||||
-rw-r--r-- | webapp/components/login/login.jsx | 66 | ||||
-rw-r--r-- | webapp/i18n/en.json | 11 | ||||
-rw-r--r-- | webapp/sass/components/_inputs.scss | 4 | ||||
-rw-r--r-- | webapp/sass/responsive/_mobile.scss | 13 | ||||
-rw-r--r-- | webapp/sass/responsive/_tablet.scss | 11 | ||||
-rw-r--r-- | webapp/sass/routes/_admin-console.scss | 5 | ||||
-rw-r--r-- | webapp/sass/routes/_signup.scss | 21 | ||||
-rw-r--r-- | webapp/stores/admin_store.jsx | 20 | ||||
-rw-r--r-- | webapp/utils/async_client.jsx | 1 | ||||
-rw-r--r-- | webapp/utils/client.jsx | 19 |
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); + } + }); +} |