diff --git a/CHANGELOG.md b/CHANGELOG.md index 2abbb64601d..2ee47ec3530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Mattermost Changelog -## UNDER DEVELOPMENT - Release v0.8.0 (Beta2) +## UNDER DEVELOPMENT - Release v1.0.0 The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the product's `master` branch to note key changes committed to master and are on their way to the next stable release. When a stable release is pushed the "UNDER DEVELOPMENT" heading is removed from the final changelog of the release. @@ -15,11 +15,23 @@ The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the produ Messaging, Comments and Notifications -- (Preview) Added support for emoji codes rendering to image files +- Support for emoji codes rendering to image files +- Full markdown support in messages, comments, and channel description +- Added ability to play video and audio files -Admin Console +System Console -- (Preview) Ability to view server logs and change config settings +- UI to change config.json settings +- Ability to view log files from console + +User Interface + +- Ability to set custom theme colors +- Replaced single color themes with pre-set themes +- Added ability to import themes from Slack + +Integrations +- (Preview) Initial support for incoming webhooks ### Improvements @@ -30,6 +42,7 @@ Documentation - Added Code Contribution Guidelines - Added new hardware sizing recommendations - Consolidated licensing information into LICENSE.txt and NOTICE.txt +- Added markdown documentation Performance @@ -41,11 +54,11 @@ Code Quality UI -- Added version, build number, build date and build hash under Account Settings -> Security (to be moved to "About" dialog later) +- Added version, build number, build date and build hash under Account Settings -> Security ### Bug Fixes -- Fixed performance issue with slow typing on iOS +- Numerous performance improvements - Fixed issue so that SSO option automatically set EmailVerified=true (it was false previously) ### Contributors diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 671c37f91ab..7c80558c4ac 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -84,8 +84,8 @@ }, { "ImportPath": "github.com/mssola/user_agent", - "Comment": "v0.4.1-2-g35c7f18", - "Rev": "35c7f18f5261cc18c698a461053c119aebaf8542" + "Comment": "v0.4.1-4-ga163d6a", + "Rev": "a163d6a569f1cd264d2f8b2bf3c5d04ace5995eb" }, { "ImportPath": "github.com/rwcarlsen/goexif/exif", diff --git a/Godeps/_workspace/src/github.com/mssola/user_agent/.travis.yml b/Godeps/_workspace/src/github.com/mssola/user_agent/.travis.yml index 92209126355..33c596acb48 100644 --- a/Godeps/_workspace/src/github.com/mssola/user_agent/.travis.yml +++ b/Godeps/_workspace/src/github.com/mssola/user_agent/.travis.yml @@ -5,6 +5,7 @@ go: - 1.2 - 1.3 - 1.4 + - 1.5 - tip matrix: allow_failures: diff --git a/Godeps/_workspace/src/github.com/mssola/user_agent/all_test.go b/Godeps/_workspace/src/github.com/mssola/user_agent/all_test.go index 6dca19d5ca1..34ccbb86451 100644 --- a/Godeps/_workspace/src/github.com/mssola/user_agent/all_test.go +++ b/Godeps/_workspace/src/github.com/mssola/user_agent/all_test.go @@ -40,6 +40,10 @@ var uastrings = []struct { {"IE11b32Win7b64MDDRJS", "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; MDDRJS; rv:11.0) like Gecko"}, {"IE11Compatibility", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.3; Trident/7.0)"}, + // Microsoft Edge + {"EdgeDesktop", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10240"}, + {"EdgeMobile", "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; DEVICE INFO) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Mobile Safari/537.36 Edge/12.10240"}, + // Gecko {"FirefoxMac", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0b8) Gecko/20100101 Firefox/4.0b8"}, {"FirefoxMacLoc", "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.6; en-US; rv:1.9.2.13) Gecko/20101203 Firefox/3.6.13"}, @@ -129,6 +133,10 @@ var expected = []string{ "Mozilla:5.0 Platform:Windows OS:Windows 7 Browser:Internet Explorer-11.0 Engine:Trident Bot:false Mobile:false", "Mozilla:4.0 Platform:Windows OS:Windows 8.1 Browser:Internet Explorer-7.0 Engine:Trident Bot:false Mobile:false", + // Microsoft Edge + "Mozilla:5.0 Platform:Windows OS:Windows NT 10.0 Browser:Edge-12.10240 Engine:EdgeHTML Bot:false Mobile:false", + "Mozilla:5.0 Platform:Windows OS:Windows Phone 10.0 Browser:Edge-12.10240 Engine:EdgeHTML Bot:false Mobile:true", + // Gecko "Mozilla:5.0 Platform:Macintosh OS:Intel Mac OS X 10.6 Browser:Firefox-4.0b8 Engine:Gecko-20100101 Bot:false Mobile:false", "Mozilla:5.0 Platform:Macintosh OS:Intel Mac OS X 10.6 Localization:en-US Browser:Firefox-3.6.13 Engine:Gecko-20101203 Bot:false Mobile:false", diff --git a/Godeps/_workspace/src/github.com/mssola/user_agent/browser.go b/Godeps/_workspace/src/github.com/mssola/user_agent/browser.go index 9fb27e5f33b..c5612db7b80 100644 --- a/Godeps/_workspace/src/github.com/mssola/user_agent/browser.go +++ b/Godeps/_workspace/src/github.com/mssola/user_agent/browser.go @@ -48,13 +48,21 @@ func (p *UserAgent) detectBrowser(sections []section) { if slen > 2 { p.browser.Version = sections[2].version if engine.name == "AppleWebKit" { - if sections[slen-1].name == "OPR" { + switch sections[slen-1].name { + case "Edge": + p.browser.Name = "Edge" + p.browser.Version = sections[slen-1].version + p.browser.Engine = "EdgeHTML" + p.browser.EngineVersion = "" + case "OPR": p.browser.Name = "Opera" p.browser.Version = sections[slen-1].version - } else if sections[2].name == "Chrome" { - p.browser.Name = "Chrome" - } else { - p.browser.Name = "Safari" + default: + if sections[2].name == "Chrome" { + p.browser.Name = "Chrome" + } else { + p.browser.Name = "Safari" + } } } else if engine.name == "Gecko" { name := sections[2].name diff --git a/NOTICE.txt b/NOTICE.txt index f3147247729..b7a7fbc1da5 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -724,6 +724,77 @@ Legal Terms Our home page can be found at http://www.freetype.org - - + --- end of FTL.TXT --- + +--- + +This product contains a modified portion of 'gemoji', a collection of emoji images and names by Apple Inc. and other contributors. + +* HOMEPAGE: + * https://github.com/github/gemoji/blob/master/LICENSE + +* LICENSE: + +octocat, squirrel, shipit +Copyright (c) 2013 GitHub Inc. All rights reserved. + +bowtie, neckbeard, fu +Copyright (c) 2013 37signals, LLC. All rights reserved. + +feelsgood, finnadie, goberserk, godmode, hurtrealbad, rage 1-4, suspect +Copyright (c) 2013 id Software. All rights reserved. + +trollface +Copyright (c) 2013 whynne@deviantart. All rights reserved. + +All other images +Copyright (c) 2013 Apple Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be included in all copies +or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +--- + +This product contains a modified portion of 'marked', a full-featured markdown parser and compiler, written in JavaScript. Built for speed. + +by Christopher Jeffrey + +* HOMEPAGE: + * https://github.com/chjj/marked + +* LICENSE: + +Copyright (c) 2011-2014, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/api/context.go b/api/context.go index 9a276a1a186..d90fbd9ee3c 100644 --- a/api/context.go +++ b/api/context.go @@ -456,18 +456,19 @@ func IsPrivateIpAddress(ipAddress string) bool { } func RenderWebError(err *model.AppError, w http.ResponseWriter, r *http.Request) { + props := make(map[string]string) + props["Message"] = err.Message + props["Details"] = err.DetailedError - protocol := GetProtocol(r) - SiteURL := protocol + "://" + r.Host - - m := make(map[string]string) - m["Message"] = err.Message - m["Details"] = err.DetailedError - m["SiteName"] = utils.Cfg.TeamSettings.SiteName - m["SiteURL"] = SiteURL + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) > 1 { + props["SiteURL"] = GetProtocol(r) + "://" + r.Host + "/" + pathParts[1] + } else { + props["SiteURL"] = GetProtocol(r) + "://" + r.Host + } w.WriteHeader(err.StatusCode) - ServerTemplates.ExecuteTemplate(w, "error.html", m) + ServerTemplates.ExecuteTemplate(w, "error.html", Page{Props: props, ClientProps: utils.ClientProperties}) } func Handle404(w http.ResponseWriter, r *http.Request) { diff --git a/api/export.go b/api/export.go index 73142a0e49d..aff34073f0b 100644 --- a/api/export.go +++ b/api/export.go @@ -87,7 +87,7 @@ func ExportTeams(writer ExportWriter, options *ExportOptions) *model.AppError { // Get the teams var teams []*model.Team if len(options.TeamsToExport) == 0 { - if result := <-Srv.Store.Team().GetForExport(); result.Err != nil { + if result := <-Srv.Store.Team().GetAll(); result.Err != nil { return result.Err } else { teams = result.Data.([]*model.Team) diff --git a/api/file.go b/api/file.go index 3606e406eba..be8fc5456d1 100644 --- a/api/file.go +++ b/api/file.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/mux" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" + "github.com/mssola/user_agent" "github.com/rwcarlsen/goexif/exif" _ "golang.org/x/image/bmp" "image" @@ -407,6 +408,19 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=2592000, public") w.Header().Set("Content-Length", strconv.Itoa(len(f))) w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(filename))) + + // attach extra headers to trigger a download on IE and Edge + ua := user_agent.New(r.UserAgent()) + bname, _ := ua.Browser() + + if bname == "Edge" || bname == "Internet Explorer" { + // trim off anything before the final / so we just get the file's name + parts := strings.Split(filename, "/") + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment;filename=\""+parts[len(parts)-1]+"\"") + } + w.Write(f) } diff --git a/api/import.go b/api/import.go index e3f314d203e..c465825b4eb 100644 --- a/api/import.go +++ b/api/import.go @@ -6,7 +6,6 @@ package api import ( l4g "code.google.com/p/log4go" "github.com/mattermost/platform/model" - "github.com/mattermost/platform/utils" ) // @@ -24,9 +23,6 @@ func ImportPost(post *model.Post) { func ImportUser(user *model.User) *model.User { user.MakeNonNil() - if len(user.Props["theme"]) == 0 { - user.AddProp("theme", utils.Cfg.TeamSettings.DefaultThemeColor) - } if result := <-Srv.Store.User().Save(user); result.Err != nil { l4g.Error("Error saving user. err=%v", result.Err) diff --git a/api/team.go b/api/team.go index c9d2412d372..4794b66df71 100644 --- a/api/team.go +++ b/api/team.go @@ -25,12 +25,12 @@ func InitTeam(r *mux.Router) { sr.Handle("/create_from_signup", ApiAppHandler(createTeamFromSignup)).Methods("POST") sr.Handle("/create_with_sso/{service:[A-Za-z]+}", ApiAppHandler(createTeamFromSSO)).Methods("POST") sr.Handle("/signup", ApiAppHandler(signupTeam)).Methods("POST") + sr.Handle("/all", ApiUserRequired(getAll)).Methods("GET") sr.Handle("/find_team_by_name", ApiAppHandler(findTeamByName)).Methods("POST") sr.Handle("/find_teams", ApiAppHandler(findTeams)).Methods("POST") sr.Handle("/email_teams", ApiAppHandler(emailTeams)).Methods("POST") sr.Handle("/invite_members", ApiUserRequired(inviteMembers)).Methods("POST") sr.Handle("/update_name", ApiUserRequired(updateTeamDisplayName)).Methods("POST") - sr.Handle("/update_valet_feature", ApiUserRequired(updateValetFeature)).Methods("POST") sr.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET") // These should be moved to the global admain console sr.Handle("/import_team", ApiUserRequired(importTeam)).Methods("POST") @@ -302,6 +302,53 @@ func isTreamCreationAllowed(c *Context, email string) bool { return true } +func getAll(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getLogs") { + return + } + + if result := <-Srv.Store.Team().GetAll(); result.Err != nil { + c.Err = result.Err + return + } else { + teams := result.Data.([]*model.Team) + m := make(map[string]*model.Team) + for _, v := range teams { + m[v.Id] = v + } + + w.Write([]byte(model.TeamMapToJson(m))) + } +} + +func revokeAllSessions(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJson(r.Body) + id := props["id"] + + if result := <-Srv.Store.Session().Get(id); result.Err != nil { + c.Err = result.Err + return + } else { + session := result.Data.(*model.Session) + + c.LogAudit("revoked_all=" + id) + + if session.IsOAuth { + RevokeAccessToken(session.Token) + } else { + sessionCache.Remove(session.Token) + + if result := <-Srv.Store.Session().Remove(session.Id); result.Err != nil { + c.Err = result.Err + return + } else { + w.Write([]byte(model.MapToJson(props))) + return + } + } + } +} + func findTeamByName(c *Context, w http.ResponseWriter, r *http.Request) { m := model.MapFromJson(r.Body) @@ -533,52 +580,6 @@ func updateTeamDisplayName(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(props))) } -func updateValetFeature(c *Context, w http.ResponseWriter, r *http.Request) { - - props := model.MapFromJson(r.Body) - - allowValetStr := props["allow_valet"] - if len(allowValetStr) == 0 { - c.SetInvalidParam("updateValetFeature", "allow_valet") - return - } - - teamId := props["team_id"] - if len(teamId) > 0 && len(teamId) != 26 { - c.SetInvalidParam("updateValetFeature", "team_id") - return - } else if len(teamId) == 0 { - teamId = c.Session.TeamId - } - - tchan := Srv.Store.Team().Get(teamId) - - if !c.HasPermissionsToTeam(teamId, "updateValetFeature") { - return - } - - if !model.IsInRole(c.Session.Roles, model.ROLE_TEAM_ADMIN) { - c.Err = model.NewAppError("updateValetFeature", "You do not have the appropriate permissions", "userId="+c.Session.UserId) - c.Err.StatusCode = http.StatusForbidden - return - } - - var team *model.Team - if tResult := <-tchan; tResult.Err != nil { - c.Err = tResult.Err - return - } else { - team = tResult.Data.(*model.Team) - } - - if result := <-Srv.Store.Team().Update(team); result.Err != nil { - c.Err = result.Err - return - } - - w.Write([]byte(model.MapToJson(props))) -} - func getMyTeam(c *Context, w http.ResponseWriter, r *http.Request) { if len(c.Session.TeamId) == 0 { diff --git a/api/team_test.go b/api/team_test.go index cd39dacfe5d..e2a7cf43086 100644 --- a/api/team_test.go +++ b/api/team_test.go @@ -132,6 +132,39 @@ func TestFindTeamByEmail(t *testing.T) { } } +func TestGetAllTeams(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if _, err := Client.GetAllTeams(); err == nil { + t.Fatal("you shouldn't have permissions") + } + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if r1, err := Client.GetAllTeams(); err != nil { + t.Fatal(err) + } else { + teams := r1.Data.(map[string]*model.Team) + if teams[team.Id].Name != team.Name { + t.Fatal() + } + } +} + /* XXXXXX investigate and fix failing test diff --git a/api/templates/error.html b/api/templates/error.html index 76057889621..6b643556e0b 100644 --- a/api/templates/error.html +++ b/api/templates/error.html @@ -23,7 +23,7 @@

{{ .ClientProps.SiteName }} needs your help:

-

{{.Message}}

+

{{ .Props.Message }}

Go back to team site
diff --git a/api/templates/signup_team_body.html b/api/templates/signup_team_body.html index f5c0e62b044..e6ffb3a5bfe 100644 --- a/api/templates/signup_team_body.html +++ b/api/templates/signup_team_body.html @@ -22,9 +22,6 @@ Set up your team

{{ .ClientProps.SiteName }} is one place for all your team communication, searchable and available anywhere.
You'll get more out of {{ .ClientProps.SiteName }} when your team is in constant communication--let's get them on board.

-

- Learn more by taking a tour -

diff --git a/api/templates/welcome_body.html b/api/templates/welcome_body.html index c75e14c6ac4..5fe3450b728 100644 --- a/api/templates/welcome_body.html +++ b/api/templates/welcome_body.html @@ -17,15 +17,9 @@ - - -
-

You joined the {{.Props.TeamDisplayName}} team at {{.ClientProps.SiteName}}!

-

Please let me know if you have any questions.
Enjoy your stay at {{.ClientProps.SiteName}}.

-
- Any questions at all, mail us any time: {{.ClientProps.FeedbackEmail}}.
- Best wishes,
- The {{.ClientProps.SiteName}} Team
+

You can sign-in to your new team from the web address:

+ {{.Props.TeamURL}} +

Mattermost lets you share messages and files from your PC or phone, with instant search and archiving.

diff --git a/api/templates/welcome_subject.html b/api/templates/welcome_subject.html index 2214f7a3820..c3b70ef20f8 100644 --- a/api/templates/welcome_subject.html +++ b/api/templates/welcome_subject.html @@ -1 +1 @@ -{{define "welcome_subject"}}Welcome to {{ .ClientProps.SiteName }}{{end}} \ No newline at end of file +{{define "welcome_subject"}}You joined {{ .Props.TeamDisplayName }}{{end}} diff --git a/api/user.go b/api/user.go index 3f26531add8..695ab2208f0 100644 --- a/api/user.go +++ b/api/user.go @@ -51,6 +51,7 @@ func InitUser(r *mux.Router) { sr.Handle("/me", ApiAppHandler(getMe)).Methods("GET") sr.Handle("/status", ApiUserRequiredActivity(getStatuses, false)).Methods("GET") sr.Handle("/profiles", ApiUserRequired(getProfiles)).Methods("GET") + sr.Handle("/profiles/{id:[A-Za-z0-9]+}", ApiUserRequired(getProfiles)).Methods("GET") sr.Handle("/{id:[A-Za-z0-9]+}", ApiUserRequired(getUser)).Methods("GET") sr.Handle("/{id:[A-Za-z0-9]+}/sessions", ApiUserRequired(getSessions)).Methods("GET") sr.Handle("/{id:[A-Za-z0-9]+}/audits", ApiUserRequired(getAudits)).Methods("GET") @@ -166,6 +167,19 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { if team.Email == user.Email { user.Roles = model.ROLE_TEAM_ADMIN channelRole = model.CHANNEL_ROLE_ADMIN + + // Below is a speical case where the first user in the entire + // system is granted the system_admin role instead of admin + if result := <-Srv.Store.User().GetTotalUsersCount(); result.Err != nil { + c.Err = result.Err + return nil + } else { + count := result.Data.(int64) + if count <= 0 { + user.Roles = model.ROLE_SYSTEM_ADMIN + } + } + } else { user.Roles = "" } @@ -184,7 +198,7 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { l4g.Error("Encountered an issue joining default channels user_id=%s, team_id=%s, err=%v", ruser.Id, ruser.TeamId, err) } - //fireAndForgetWelcomeEmail(ruser.FirstName, ruser.Email, team.Name, c.TeamURL+"/channels/town-square") + fireAndForgetWelcomeEmail(ruser.Email, team.DisplayName, c.GetTeamURLFromTeam(team)) if user.EmailVerified { if cresult := <-Srv.Store.User().VerifyEmail(ruser.Id); cresult.Err != nil { l4g.Error("Failed to set email verified err=%v", cresult.Err) @@ -204,17 +218,13 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { } } -func fireAndForgetWelcomeEmail(name, email, teamDisplayName, link, siteURL string) { +func fireAndForgetWelcomeEmail(email, teamDisplayName, teamURL string) { go func() { subjectPage := NewServerTemplatePage("welcome_subject") - subjectPage.Props["SiteURL"] = siteURL + subjectPage.Props["TeamDisplayName"] = teamDisplayName bodyPage := NewServerTemplatePage("welcome_body") - bodyPage.Props["SiteURL"] = siteURL - bodyPage.Props["Nickname"] = name - bodyPage.Props["TeamDisplayName"] = teamDisplayName - bodyPage.Props["FeedbackName"] = utils.Cfg.EmailSettings.FeedbackName - bodyPage.Props["TeamURL"] = link + bodyPage.Props["TeamURL"] = teamURL if err := utils.SendMail(email, subjectPage.Render(), bodyPage.Render()); err != nil { l4g.Error("Failed to send welcome email successfully err=%v", err) @@ -550,13 +560,26 @@ func getUser(c *Context, w http.ResponseWriter, r *http.Request) { } func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id, ok := params["id"] + if ok { + // You must be system admin to access another team + if id != c.Session.TeamId { + if !c.HasSystemAdminPermissions("getProfiles") { + return + } + } - etag := (<-Srv.Store.User().GetEtagForProfiles(c.Session.TeamId)).Data.(string) + } else { + id = c.Session.TeamId + } + + etag := (<-Srv.Store.User().GetEtagForProfiles(id)).Data.(string) if HandleEtag(etag, w, r) { return } - if result := <-Srv.Store.User().GetProfiles(c.Session.TeamId); result.Err != nil { + if result := <-Srv.Store.User().GetProfiles(id); result.Err != nil { c.Err = result.Err return } else { @@ -794,6 +817,9 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { Srv.Store.User().UpdateLastPictureUpdate(c.Session.UserId) c.LogAudit("") + + // write something as the response since jQuery expects a json response + w.Write([]byte("true")) } func updateUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -924,8 +950,8 @@ func updateRoles(c *Context, w http.ResponseWriter, r *http.Request) { return } - if model.IsInRole(new_roles, model.ROLE_SYSTEM_ADMIN) { - c.Err = model.NewAppError("updateRoles", "The system_admin role can only be set from the command line", "") + if model.IsInRole(new_roles, model.ROLE_SYSTEM_ADMIN) && !c.IsSystemAdmin() { + c.Err = model.NewAppError("updateRoles", "The system_admin role can only be set by another system admin", "") c.Err.StatusCode = http.StatusForbidden return } @@ -1155,32 +1181,38 @@ func resetPassword(c *Context, w http.ResponseWriter, r *http.Request) { return } - hash := props["hash"] - if len(hash) == 0 { - c.SetInvalidParam("resetPassword", "hash") - return - } - - data := model.MapFromJson(strings.NewReader(props["data"])) - - userId := data["user_id"] - if len(userId) != 26 { - c.SetInvalidParam("resetPassword", "data:user_id") - return - } - - timeStr := data["time"] - if len(timeStr) == 0 { - c.SetInvalidParam("resetPassword", "data:time") - return - } - name := props["name"] if len(name) == 0 { c.SetInvalidParam("resetPassword", "name") return } + userId := props["user_id"] + hash := props["hash"] + timeStr := "" + + if !c.IsSystemAdmin() { + if len(hash) == 0 { + c.SetInvalidParam("resetPassword", "hash") + return + } + + data := model.MapFromJson(strings.NewReader(props["data"])) + + userId = data["user_id"] + + timeStr = data["time"] + if len(timeStr) == 0 { + c.SetInvalidParam("resetPassword", "data:time") + return + } + } + + if len(userId) != 26 { + c.SetInvalidParam("resetPassword", "user_id") + return + } + c.LogAuditWithUserId(userId, "attempt") var team *model.Team @@ -1205,15 +1237,17 @@ func resetPassword(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", props["data"], utils.Cfg.EmailSettings.PasswordResetSalt)) { - c.Err = model.NewAppError("resetPassword", "The reset password link does not appear to be valid", "") - return - } + if !c.IsSystemAdmin() { + if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", props["data"], utils.Cfg.EmailSettings.PasswordResetSalt)) { + c.Err = model.NewAppError("resetPassword", "The reset password link does not appear to be valid", "") + return + } - t, err := strconv.ParseInt(timeStr, 10, 64) - if err != nil || model.GetMillis()-t > 1000*60*60 { // one hour - c.Err = model.NewAppError("resetPassword", "The reset link has expired", "") - return + t, err := strconv.ParseInt(timeStr, 10, 64) + if err != nil || model.GetMillis()-t > 1000*60*60 { // one hour + c.Err = model.NewAppError("resetPassword", "The reset link has expired", "") + return + } } if result := <-Srv.Store.User().UpdatePassword(userId, model.HashPassword(newPassword)); result.Err != nil { diff --git a/api/user_test.go b/api/user_test.go index 669da4d2080..baa567deca5 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -228,6 +228,13 @@ func TestGetUser(t *testing.T) { ruser2, _ := Client.CreateUser(&user2, "") store.Must(Srv.Store.User().VerifyEmail(ruser2.Data.(*model.User).Id)) + team2 := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + rteam2, _ := Client.CreateTeam(&team2) + + user3 := model.User{TeamId: rteam2.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + ruser3, _ := Client.CreateUser(&user3, "") + store.Must(Srv.Store.User().VerifyEmail(ruser3.Data.(*model.User).Id)) + Client.LoginByEmail(team.Name, user.Email, user.Password) rId := ruser.Data.(*model.User).Id @@ -276,13 +283,27 @@ func TestGetUser(t *testing.T) { t.Log(cache_result.Data) t.Fatal("cache should be empty") } + } + if _, err := Client.GetProfiles(rteam2.Data.(*model.Team).Id, ""); err == nil { + t.Fatal("shouldn't have access") } Client.AuthToken = "" if _, err := Client.GetUser(ruser2.Data.(*model.User).Id, ""); err == nil { t.Fatal("shouldn't have accss") } + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + UpdateRoles(c, ruser.Data.(*model.User), model.ROLE_SYSTEM_ADMIN) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if _, err := Client.GetProfiles(rteam2.Data.(*model.Team).Id, ""); err != nil { + t.Fatal(err) + } } func TestGetAudits(t *testing.T) { diff --git a/config/config.json b/config/config.json index aa92ccf4eb6..60e197154c8 100644 --- a/config/config.json +++ b/config/config.json @@ -11,7 +11,6 @@ "TeamSettings": { "SiteName": "Mattermost", "MaxUsersPerTeam": 50, - "DefaultThemeColor": "#2389D7", "EnableTeamCreation": true, "EnableUserCreation": true, "RestrictCreationToDomains": "" diff --git a/doc/README.md b/doc/README.md index 6bf96492ad8..7713c40ab27 100644 --- a/doc/README.md +++ b/doc/README.md @@ -23,3 +23,7 @@ - [Code Contribution Guidelines](developer/code-contribution.md) - [Developer Machine Setup](install/dev-setup.md) - [Mattermost Style Guide](developer/style-guide.md) + +## End User Help + +- [Mattermost Markdown Formatting](help/enduser/markdown.md) diff --git a/doc/help/enduser/markdown.md b/doc/help/enduser/markdown.md new file mode 100644 index 00000000000..9e2342a0b7d --- /dev/null +++ b/doc/help/enduser/markdown.md @@ -0,0 +1,152 @@ +# Markdown Help + +Markdown makes it easy to format messages. Type a message as you normally would, and use these rules to render it with special formatting. + +## Text Style: + +You can use either `_` or `*` around a word to make it italic. Use two to make it bold. + +* `_italics_` renders as _italics_ +* `**bold**` renders as **bold** +* `**_bold-italic_**` renders as **_bold-italics_** +* `~~strikethrough~~` renders as ~~strikethrough~~ + +## Code: + +Create a code block by indenting four spaces, or by placing ``` on the line above and below your code. + +Example: + + ``` + code block + ``` + +Renders as: +``` +code block +``` + +Create in-line monospaced font by surrounding it with back spaces. +``` +`monospace` +``` +Renders as: `monospace`. + +## Links: + +Create labeled links by putting the desired text in square brackets and the associated link in normal brackets. + +`[Check out Mattermost!](www.mattermost.com)` + +Renders as: [Check out Mattermost!](www.mattermost.com) + +## In-line Images + +Create in-line images using an `!` followed by the alt text in square brackets and the link in normal brackets. Add hover text by placing it in quotes after the link. +``` +![alt text](link "hover text") + +and + +[![Build Status](https://travis-ci.org/mattermost/platform.svg?branch=master)](https://travis-ci.org/mattermost/platform) [![Github](https://assets-cdn.github.com/favicon.ico)](https://github.com/mattermost/platform) +``` +Renders as: + +![alt text](link "hover text") + +and + +[![Build Status](https://travis-ci.org/mattermost/platform.svg?branch=master)](https://travis-ci.org/mattermost/platform) [![Github](https://assets-cdn.github.com/favicon.ico)](https://github.com/mattermost/platform) + +## Emojis + +Check out a full list of emojis [here](http://www.emoji-cheat-sheet.com/). + +``` +:smile: :+1: :sheep: +``` +Renders as: +:smile: :+1: :sheep: + +## Lines: + +Create a line by using three `*`, `_`, or `-`. + +`***` renders as: +*** + +## Block quotes: + +Create block quotes using `>`. + +`> block quotes` renders as: +> block quotes + +## Lists: + +Create a list by using `*` or `-` as bullets. Indent a bullet point by adding two spaces in front of it. +``` +* list item one +* list item two + * item two sub-point +``` +Renders as: +* list item one +* list item two + * item two sub-point + +Make it an ordered list by using numbers instead: +``` +1. Item one +2. Item two +``` +Renders as: +1. Item one +2. Item two + +## Tables: + +Create a table by placing a dashed line under the header row and separating the columns with a pipe `|`. (The columns don’t need to line up exactly for it to work). Choose how to align table columns by including colons `:` within the header row. +``` +| Left-Aligned  | Center Aligned  | Right Aligned | +| :------------ |:---------------:| -----:| +| Left column 1 | this text       |  $100 | +| Left column 2 | is              |   $10 | +| Left column 3 | centered        |    $1 | +``` + +Renders as: + +| Left-Aligned  | Center Aligned  | Right Aligned | +| :------------ |:---------------:| -----:| +| Left column 1 | this text       |  $100 | +| Left column 2 | is              |   $10 | +| Left column 3 | centered        |    $1 | + +## Headings: + +Make a heading by typing # and a space before your title. For smaller headings, use more #’s. +``` +# Large heading +## Smaller heading +### Even smaller heading +``` +Renders as: +# Large Heading +## Smaller Heading +### Even smaller heading + +Alternatively, for the large heading you can underline the text using `===`. For the smaller heading you can underline using `---` +``` +Large Heading +============= + +Smaller Heading +-------------- +``` +Renders as: +Large Heading +============= + +Smaller Heading +-------------- diff --git a/doc/install/single-container-install.md b/doc/install/single-container-install.md index 772f3becf82..fa5265773f9 100644 --- a/doc/install/single-container-install.md +++ b/doc/install/single-container-install.md @@ -4,11 +4,11 @@ The following install instructions are for single-container installs of Mattermo ### Mac OSX ### -1. Install Boot2Docker using instructions at: http://docs.docker.com/installation/mac/ - 1. Start Boot2Docker from the command line and run: `boot2docker init eval “$(boot2docker shellinit)”` -2. Get your Docker IP address with: `boot2docker ip` +1. Install Docker Toolbox using instructions at: http://docs.docker.com/installation/mac/ + 1. Start Docker Toolbox from the command line and run: `docker-machine create -d virtualbox dev”` +2. Get your Docker IP address with: `docker-machine ip dev` 3. Use `sudo nano /etc/hosts` to add ` dockerhost` to your /etc/hosts file -4. Run: `boot2docker shellinit` and copy the export statements to your ~/.bash\_profile by running `sudo nano ~/.bash_profile`. Then run: `source ~/.bash_profile` +4. Run: `docker-machine env dev` and copy the export statements to your ~/.bash\_profile by running `sudo nano ~/.bash_profile`. Then run: `source ~/.bash_profile` 5. Run: `docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform` 6. When docker is done fetching the image, open http://dockerhost:8065/ in your browser. diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json index 16a4007fa0b..06fee9bd5c7 100644 --- a/docker/dev/config_docker.json +++ b/docker/dev/config_docker.json @@ -11,7 +11,6 @@ "TeamSettings": { "SiteName": "Mattermost", "MaxUsersPerTeam": 50, - "DefaultThemeColor": "#2389D7", "EnableTeamCreation": true, "EnableUserCreation": true, "RestrictCreationToDomains": "" diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json index 16a4007fa0b..06fee9bd5c7 100644 --- a/docker/local/config_docker.json +++ b/docker/local/config_docker.json @@ -11,7 +11,6 @@ "TeamSettings": { "SiteName": "Mattermost", "MaxUsersPerTeam": 50, - "DefaultThemeColor": "#2389D7", "EnableTeamCreation": true, "EnableUserCreation": true, "RestrictCreationToDomains": "" diff --git a/mattermost.go b/mattermost.go index 4608bfff3e6..ff7b0f3f3a7 100644 --- a/mattermost.go +++ b/mattermost.go @@ -325,10 +325,10 @@ Usage: -role="admin" The role used in other commands valid values are - "" - The empty role is basic user + "" - The empty role is basic user permissions "admin" - Represents a team admin and - is used to help adminsiter one team. + is used to help administer one team. "system_admin" - Represents a system admin who has access to all teams and configuration settings. This diff --git a/model/client.go b/model/client.go index cc75ce3705d..26e00864d29 100644 --- a/model/client.go +++ b/model/client.go @@ -150,6 +150,16 @@ func (c *Client) CreateTeam(team *Team) (*Result, *AppError) { } } +func (c *Client) GetAllTeams() (*Result, *AppError) { + if r, err := c.DoApiGet("/teams/all", "", ""); err != nil { + return nil, err + } else { + + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamMapFromJson(r.Body)}, nil + } +} + func (c *Client) FindTeamByName(name string, allServers bool) (*Result, *AppError) { m := make(map[string]string) m["name"] = name @@ -208,15 +218,6 @@ func (c *Client) UpdateTeamDisplayName(data map[string]string) (*Result, *AppErr } } -func (c *Client) UpdateValetFeature(data map[string]string) (*Result, *AppError) { - if r, err := c.DoApiPost("/teams/update_valet_feature", MapToJson(data)); err != nil { - return nil, err - } else { - return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil - } -} - func (c *Client) CreateUser(user *User, hash string) (*Result, *AppError) { if r, err := c.DoApiPost("/users/create", user.ToJson()); err != nil { return nil, err @@ -254,7 +255,7 @@ func (c *Client) GetMe(etag string) (*Result, *AppError) { } func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/profiles", "", etag); err != nil { + if r, err := c.DoApiGet("/users/profiles/"+teamId, "", etag); err != nil { return nil, err } else { return &Result{r.Header.Get(HEADER_REQUEST_ID), @@ -570,15 +571,6 @@ func (c *Client) CreatePost(post *Post) (*Result, *AppError) { } } -func (c *Client) CreateValetPost(post *Post) (*Result, *AppError) { - if r, err := c.DoApiPost("/channels/"+post.ChannelId+"/valet_create", post.ToJson()); err != nil { - return nil, err - } else { - return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), PostFromJson(r.Body)}, nil - } -} - func (c *Client) UpdatePost(post *Post) (*Result, *AppError) { if r, err := c.DoApiPost("/channels/"+post.ChannelId+"/update", post.ToJson()); err != nil { return nil, err diff --git a/model/config.go b/model/config.go index e711f952263..853e2bbc0a0 100644 --- a/model/config.go +++ b/model/config.go @@ -112,7 +112,6 @@ type PrivacySettings struct { type TeamSettings struct { SiteName string MaxUsersPerTeam int - DefaultThemeColor string EnableTeamCreation bool EnableUserCreation bool RestrictCreationToDomains string diff --git a/model/team.go b/model/team.go index 0d740dde27a..f80fa3b116f 100644 --- a/model/team.go +++ b/model/team.go @@ -73,6 +73,26 @@ func TeamFromJson(data io.Reader) *Team { } } +func TeamMapToJson(u map[string]*Team) string { + b, err := json.Marshal(u) + if err != nil { + return "" + } else { + return string(b) + } +} + +func TeamMapFromJson(data io.Reader) map[string]*Team { + decoder := json.NewDecoder(data) + var teams map[string]*Team + err := decoder.Decode(&teams) + if err == nil { + return teams + } else { + return nil + } +} + func (o *Team) Etag() string { return Etag(o.Id, o.UpdateAt) } diff --git a/model/user.go b/model/user.go index 3a2c9d56cfe..5cb77447868 100644 --- a/model/user.go +++ b/model/user.go @@ -23,7 +23,6 @@ const ( USER_NOTIFY_ALL = "all" USER_NOTIFY_MENTION = "mention" USER_NOTIFY_NONE = "none" - BOT_USERNAME = "valet" ) type User struct { diff --git a/store/sql_session_store.go b/store/sql_session_store.go index c1d2c852b66..22411389d75 100644 --- a/store/sql_session_store.go +++ b/store/sql_session_store.go @@ -140,6 +140,24 @@ func (me SqlSessionStore) Remove(sessionIdOrToken string) StoreChannel { return storeChannel } +func (me SqlSessionStore) RemoveAllSessionsForTeam(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := me.GetMaster().Exec("DELETE FROM Sessions WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}) + if err != nil { + result.Err = model.NewAppError("SqlSessionStore.RemoveAllSessionsForTeam", "We couldn't remove all the sessions for the team", "id="+teamId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (me SqlSessionStore) CleanUpExpiredSessions(userId string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_session_store_test.go b/store/sql_session_store_test.go index 4ae680556a9..3d8aafe2579 100644 --- a/store/sql_session_store_test.go +++ b/store/sql_session_store_test.go @@ -80,6 +80,29 @@ func TestSessionRemove(t *testing.T) { } } +func TestSessionRemoveAll(t *testing.T) { + Setup() + + s1 := model.Session{} + s1.UserId = model.NewId() + s1.TeamId = model.NewId() + Must(store.Session().Save(&s1)) + + if rs1 := (<-store.Session().Get(s1.Id)); rs1.Err != nil { + t.Fatal(rs1.Err) + } else { + if rs1.Data.(*model.Session).Id != s1.Id { + t.Fatal("should match") + } + } + + Must(store.Session().RemoveAllSessionsForTeam(s1.TeamId)) + + if rs2 := (<-store.Session().Get(s1.Id)); rs2.Err == nil { + t.Fatal("should have been removed") + } +} + func TestSessionRemoveToken(t *testing.T) { Setup() diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 3d644e57767..109fe540191 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -196,7 +196,7 @@ func (s SqlTeamStore) GetTeamsForEmail(email string) StoreChannel { return storeChannel } -func (s SqlTeamStore) GetForExport() StoreChannel { +func (s SqlTeamStore) GetAll() StoreChannel { storeChannel := make(StoreChannel) go func() { @@ -204,7 +204,7 @@ func (s SqlTeamStore) GetForExport() StoreChannel { var data []*model.Team if _, err := s.GetReplica().Select(&data, "SELECT * FROM Teams"); err != nil { - result.Err = model.NewAppError("SqlTeamStore.GetForExport", "We could not get all teams", err.Error()) + result.Err = model.NewAppError("SqlTeamStore.GetAllTeams", "We could not get all teams", err.Error()) } result.Data = data diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 3fd1c82b517..0a723d96589 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -481,3 +481,22 @@ func (us SqlUserStore) GetForExport(teamId string) StoreChannel { return storeChannel } + +func (us SqlUserStore) GetTotalUsersCount() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if count, err := us.GetReplica().SelectInt("SELECT COUNT(Id) FROM Users"); err != nil { + result.Err = model.NewAppError("SqlUserStore.GetTotalUsersCount", "We could not count the users", err.Error()) + } else { + result.Data = count + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go index 466da28452d..e2a45402385 100644 --- a/store/sql_user_store_test.go +++ b/store/sql_user_store_test.go @@ -206,6 +206,24 @@ func TestUserStoreGet(t *testing.T) { } } +func TestUserCountt(t *testing.T) { + Setup() + + u1 := model.User{} + u1.TeamId = model.NewId() + u1.Email = model.NewId() + Must(store.User().Save(&u1)) + + if result := <-store.User().GetTotalUsersCount(); result.Err != nil { + t.Fatal(result.Err) + } else { + count := result.Data.(int64) + if count <= 0 { + t.Fatal() + } + } +} + func TestUserStoreGetProfiles(t *testing.T) { Setup() diff --git a/store/store.go b/store/store.go index c9d40cfa5ea..23580f45255 100644 --- a/store/store.go +++ b/store/store.go @@ -47,7 +47,7 @@ type TeamStore interface { Get(id string) StoreChannel GetByName(name string) StoreChannel GetTeamsForEmail(domain string) StoreChannel - GetForExport() StoreChannel + GetAll() StoreChannel } type ChannelStore interface { @@ -103,6 +103,7 @@ type UserStore interface { GetEtagForProfiles(teamId string) StoreChannel UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel GetForExport(teamId string) StoreChannel + GetTotalUsersCount() StoreChannel } type SessionStore interface { @@ -110,6 +111,7 @@ type SessionStore interface { Get(sessionIdOrToken string) StoreChannel GetSessions(userId string) StoreChannel Remove(sessionIdOrToken string) StoreChannel + RemoveAllSessionsForTeam(teamId string) StoreChannel UpdateLastActivityAt(sessionId string, time int64) StoreChannel UpdateRoles(userId string, roles string) StoreChannel } diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx index 7cbd4021e20..fe40385a094 100644 --- a/web/react/components/activity_log_modal.jsx +++ b/web/react/components/activity_log_modal.jsx @@ -25,7 +25,8 @@ export default class ActivityLogModal extends React.Component { clientError: null }; } - submitRevoke(altId) { + submitRevoke(altId, e) { + e.preventDefault(); Client.revokeSession(altId, function handleRevokeSuccess() { AsyncClient.getSessions(); @@ -86,7 +87,7 @@ export default class ActivityLogModal extends React.Component {
{`First time active: ${firstAccessTime.toDateString()}, ${lastAccessTime.toLocaleTimeString()}`}
{`OS: ${currentSession.props.os}`}
{`Browser: ${currentSession.props.browser}`}
-
{`Session ID: ${currentSession.alt_id}`}
+
{`Session ID: ${currentSession.id}`}
); } else { @@ -115,7 +116,7 @@ export default class ActivityLogModal extends React.Component {
@@ -279,7 +279,7 @@ export default class EmailSettings extends React.Component { /> {'false'} -

{'Typically set to true in production. When true Mattermost will not allow a user to login without first having recieved an email with a verification link. Developers may set this field to false so skip sending verification emails for faster development.'}

+

{'Typically set to true in production. When true, Mattermost requires email verification after account creation prior to allowing login. Developers may set this field to false so skip sending verification emails for faster development.'}

@@ -296,12 +296,12 @@ export default class EmailSettings extends React.Component { className='form-control' id='feedbackName' ref='feedbackName' - placeholder='Ex: "Mattermost", "System", "John Smith"' + placeholder='Ex: "Mattermost Notification", "System", "No-Reply"' defaultValue={this.props.config.EmailSettings.FeedbackName} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} /> -

{'Name displayed on email account used when sending notification emails from Mattermost.'}

+

{'Display name on email account used when sending notification emails from Mattermost.'}

@@ -323,7 +323,7 @@ export default class EmailSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} /> -

{'Email displayed on email account used when sending notification emails from Mattermost.'}

+

{'Email address displayed on email account used when sending notification emails from Mattermost.'}

@@ -479,7 +479,7 @@ export default class EmailSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} /> -

{'32-character salt added to signing of email invites.'}

+

{'32-character salt added to signing of email invites. Randomly generated on install. Click "Re-Generate" to create new salt.'}

@@ -136,7 +136,7 @@ export default class GitLabSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.Allow} /> -

{'Need help text.'}

+

{'Obtain this value via the instructions above for logging into GitLab.'}

@@ -158,7 +158,7 @@ export default class GitLabSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.Allow} /> -

{'Need help text.'}

+

{'Obtain this value via the instructions above for logging into GitLab'}

@@ -175,12 +175,12 @@ export default class GitLabSettings extends React.Component { className='form-control' id='Scope' ref='Scope' - placeholder='Ex ""' + placeholder='Not currently used by GitLab. Please leave blank' defaultValue={this.props.config.GitLabSettings.Scope} onChange={this.handleChange} disabled={!this.state.Allow} /> -

{'Need help text.'}

+

{'This field is not yet used by GitLab OAuth. Other OAuth providers may use this field to specify the scope of account data from OAuth provider that is sent to Mattermost.'}

@@ -202,7 +202,7 @@ export default class GitLabSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.Allow} /> -

{'Need help text.'}

+

{'Enter /oauth/authorize (example http://localhost:3000/oauth/authorize).'}

@@ -224,7 +224,7 @@ export default class GitLabSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.Allow} /> -

{'Need help text.'}

+

{'Enter /oauth/token.'}

@@ -246,7 +246,7 @@ export default class GitLabSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.Allow} /> -

{'Need help text.'}

+

{'Enter /api/v3/user.'}

diff --git a/web/react/components/admin_console/image_settings.jsx b/web/react/components/admin_console/image_settings.jsx index 25d5ad857d9..e52f516e8a8 100644 --- a/web/react/components/admin_console/image_settings.jsx +++ b/web/react/components/admin_console/image_settings.jsx @@ -457,7 +457,7 @@ export default class FileSettings extends React.Component { defaultValue={this.props.config.FileSettings.PublicLinkSalt} onChange={this.handleChange} /> -

{'32-character salt added to signing of public image links.'}

+

{'32-character salt added to signing of public image links. Randomly generated on install. Click "Re-Generate" to create new salt.'}

@@ -149,7 +149,7 @@ export default class LogSettings extends React.Component { -

{'This setting determines the level of detail at which log events are written to the console. ERROR: Outputs only error messages. INFO: Outputs error messages and information around startup and initialization. DEBUG: Prints high detail for developers debugging issues working on debugging issues.'}

+

{'This setting determines the level of detail at which log events are written to the console. ERROR: Outputs only error messages. INFO: Outputs error messages and information around startup and initialization. DEBUG: Prints high detail for developers working on debugging issues.'}

@@ -181,7 +181,7 @@ export default class LogSettings extends React.Component { /> {'false'} -

{'Typically set to true in production. When true log files are written to the file specified in file location field below.'}

+

{'Typically set to true in production. When true, log files are written to the log file specified in file location field below.'}

@@ -205,7 +205,7 @@ export default class LogSettings extends React.Component { -

{'This setting determines the level of detail at which log events are written to the file. ERROR: Outputs only error messages. INFO: Outputs error messages and information around startup and initialization. DEBUG: Prints high detail for developers debugging issues working on debugging issues.'}

+

{'This setting determines the level of detail at which log events are written to the log file. ERROR: Outputs only error messages. INFO: Outputs error messages and information around startup and initialization. DEBUG: Prints high detail for developers working on debugging issues.'}

@@ -227,7 +227,7 @@ export default class LogSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.fileEnable} /> -

{'File to which log files are written. If blank, will be set to ./logs/mattermost.log. Log rotation is enabled and new files may be created in the same directory.'}

+

{'File to which log files are written. If blank, will be set to ./logs/mattermost, which writes logs to mattermost.log. Log rotation is enabled and every 10,000 lines of log information is written to new files stored in the same directory, for example mattermost.2015-09-23.001, mattermost.2015-09-23.002, and so forth.'}

diff --git a/web/react/components/admin_console/privacy_settings.jsx b/web/react/components/admin_console/privacy_settings.jsx index 8ce69392582..affd8ae116d 100644 --- a/web/react/components/admin_console/privacy_settings.jsx +++ b/web/react/components/admin_console/privacy_settings.jsx @@ -99,7 +99,7 @@ export default class PrivacySettings extends React.Component { /> {'false'} -

{'Hides email address of users from other users including team administrator.'}

+

{'When false, hides email address of users from other users in the user interface, including team owners and team administrators. Used when system is set up for managing teams where some users choose to keep their contact information private.'}

@@ -132,7 +132,7 @@ export default class PrivacySettings extends React.Component { /> {'false'} -

{'Hides full name of users from other users including team administrator.'}

+

{'When false, hides full name of users from other users including team owner and team administrators.'}

diff --git a/web/react/components/admin_console/rate_settings.jsx b/web/react/components/admin_console/rate_settings.jsx index c05bf4a8201..0081daca331 100644 --- a/web/react/components/admin_console/rate_settings.jsx +++ b/web/react/components/admin_console/rate_settings.jsx @@ -140,7 +140,7 @@ export default class RateSettings extends React.Component { /> {'false'} -

{'When enabled throttles rate at which APIs respond.'}

+

{'When true, APIs are throttled at rates specified below.'}

@@ -184,7 +184,7 @@ export default class RateSettings extends React.Component { onChange={this.handleChange} disabled={!this.state.EnableRateLimiter} /> -

{'Maximum number of users sessions connected to the system as determined by VaryByRemoteAddr and VaryByHeader variables.'}

+

{'Maximum number of users sessions connected to the system as determined by "Vary By Remote Address" and "Vary By Header" settings below.'}

@@ -193,7 +193,7 @@ export default class RateSettings extends React.Component { className='control-label col-sm-4' htmlFor='VaryByRemoteAddr' > - {'Limit By Remote Address: '} + {'Vary By Remote Address: '}
-

{'Rate limit API access by IP address.'}

+

{'When true, rate limit API access by IP address.'}

@@ -228,7 +228,7 @@ export default class RateSettings extends React.Component { className='control-label col-sm-4' htmlFor='VaryByHeader' > - {'Limit By Http Header:'} + {'Vary By HTTP Header:'}
-

{'When filled in, vary rate limiting by http header field specified (e.g. when configuring ngnix set to "X-Real-IP", when configuring AmazonELB set to "X-Forwarded-For").'}

+

{'When filled in, vary rate limiting by HTTP header field specified (e.g. when configuring Ngnix set to "X-Real-IP", when configuring AmazonELB set to "X-Forwarded-For").'}

diff --git a/web/react/components/admin_console/reset_password_modal.jsx b/web/react/components/admin_console/reset_password_modal.jsx new file mode 100644 index 00000000000..0b83edb1708 --- /dev/null +++ b/web/react/components/admin_console/reset_password_modal.jsx @@ -0,0 +1,132 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var Modal = ReactBootstrap.Modal; + +export default class ResetPasswordModal extends React.Component { + constructor(props) { + super(props); + + this.doSubmit = this.doSubmit.bind(this); + this.doCancel = this.doCancel.bind(this); + + this.state = { + serverError: null + }; + } + + doSubmit(e) { + e.preventDefault(); + var password = React.findDOMNode(this.refs.password).value; + + if (!password || password.length < 5) { + this.setState({serverError: 'Please enter at least 5 characters.'}); + return; + } + + this.setState({serverError: null}); + + var data = {}; + data.new_password = password; + data.name = this.props.team.name; + data.user_id = this.props.user.id; + + Client.resetPassword(data, + () => { + this.props.onModalSubmit(React.findDOMNode(this.refs.password).value); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + doCancel() { + this.setState({serverError: null}); + this.props.onModalDismissed(); + } + + render() { + if (this.props.user == null) { + return
; + } + + let urlClass = 'input-group input-group--limit'; + let serverError = null; + + if (this.state.serverError) { + urlClass += ' has-error'; + serverError =

{this.state.serverError}

; + } + + return ( + + + {'Reset Password'} + +
+ +
+
+
+ + {'New Password'} + + +
+ {serverError} +
+
+
+ + + + +
+
+ ); + } +} + +ResetPasswordModal.defaultProps = { + show: false +}; + +ResetPasswordModal.propTypes = { + user: React.PropTypes.object, + team: React.PropTypes.object, + show: React.PropTypes.bool.isRequired, + onModalSubmit: React.PropTypes.func, + onModalDismissed: React.PropTypes.func +}; diff --git a/web/react/components/admin_console/select_team_modal.jsx b/web/react/components/admin_console/select_team_modal.jsx index fa30de7b2c7..343f6513161 100644 --- a/web/react/components/admin_console/select_team_modal.jsx +++ b/web/react/components/admin_console/select_team_modal.jsx @@ -1,124 +1,99 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. -export default class SelectTeam extends React.Component { +var Modal = ReactBootstrap.Modal; + +export default class SelectTeamModal extends React.Component { constructor(props) { super(props); - this.state = { - }; + this.doSubmit = this.doSubmit.bind(this); + this.doCancel = this.doCancel.bind(this); } + doSubmit(e) { + e.preventDefault(); + this.props.onModalSubmit(React.findDOMNode(this.refs.team).value); + } + doCancel() { + this.props.onModalDismissed(); + } render() { + if (this.props.teams == null) { + return
; + } + + var options = []; + + for (var key in this.props.teams) { + if (this.props.teams.hasOwnProperty(key)) { + var team = this.props.teams[key]; + options.push( + + ); + } + } + return ( -
@@ -170,45 +170,12 @@ export default class ServiceSettings extends React.Component {
-
- -
- - -

{'When enabled Mattermost will act as an Oauth2 Provider. Changing this will require a server restart before taking effect.'}

-
-
-
-

{'When true incomming web hooks will be allowed.'}

+

{'When true, incoming webhooks will be allowed.'}

@@ -265,7 +232,7 @@ export default class ServiceSettings extends React.Component { /> {'false'} -

{'When true slash commands like /loadtest are enabled in the add comment box. Changing this will require a server restart before taking effect. Typically used for development.'}

+

{'(Developer Option) When true, /loadtest slash command is enabled to load test accounts and test data. Changing this will require a server restart before taking effect.'}

@@ -291,6 +258,39 @@ export default class ServiceSettings extends React.Component { } } +//
+// +//
+// +// +//

{'When enabled Mattermost will act as an OAuth2 Provider. Changing this will require a server restart before taking effect.'}

+//
+//
+ ServiceSettings.propTypes = { config: React.PropTypes.object }; diff --git a/web/react/components/admin_console/sql_settings.jsx b/web/react/components/admin_console/sql_settings.jsx index ad01b596310..430a7453b64 100644 --- a/web/react/components/admin_console/sql_settings.jsx +++ b/web/react/components/admin_console/sql_settings.jsx @@ -252,7 +252,7 @@ export default class SqlSettings extends React.Component { /> {'false'} -

{'Output executing SQL statements to the log. Typically used for development.'}

+

{'(Development Mode) When true, executing SQL statements are written to the log.'}

diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx index fefc0e936d3..0f6f819d397 100644 --- a/web/react/components/admin_console/team_settings.jsx +++ b/web/react/components/admin_console/team_settings.jsx @@ -28,7 +28,6 @@ export default class TeamSettings extends React.Component { var config = this.props.config; config.TeamSettings.SiteName = React.findDOMNode(this.refs.SiteName).value.trim(); - config.TeamSettings.DefaultThemeColor = React.findDOMNode(this.refs.DefaultThemeColor).value.trim(); config.TeamSettings.RestrictCreationToDomains = React.findDOMNode(this.refs.RestrictCreationToDomains).value.trim(); config.TeamSettings.EnableTeamCreation = React.findDOMNode(this.refs.EnableTeamCreation).checked; config.TeamSettings.EnableUserCreation = React.findDOMNode(this.refs.EnableUserCreation).checked; @@ -118,28 +117,7 @@ export default class TeamSettings extends React.Component { defaultValue={this.props.config.TeamSettings.MaxUsersPerTeam} onChange={this.handleChange} /> -

{'Maximum number of users per team.'}

- - - -
- -
- -

{'Default theme color for team sites.'}

+

{'Maximum total number of users per team, including both active and inactive users.'}

@@ -172,7 +150,7 @@ export default class TeamSettings extends React.Component { /> {'false'} -

{'When false the ability to create teams is disabled. The create team button displays error when pressed.'}

+

{'When false, the ability to create teams is disabled. The create team button displays error when pressed.'}

@@ -205,7 +183,7 @@ export default class TeamSettings extends React.Component { /> {'false'} -

{'When false the ability to create accounts is disabled. The create account button displays error when pressed.'}

+

{'When false, the ability to create accounts is disabled. The create account button displays error when pressed.'}

diff --git a/web/react/components/admin_console/team_users.jsx b/web/react/components/admin_console/team_users.jsx new file mode 100644 index 00000000000..0a971ff159e --- /dev/null +++ b/web/react/components/admin_console/team_users.jsx @@ -0,0 +1,178 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var LoadingScreen = require('../loading_screen.jsx'); +var UserItem = require('./user_item.jsx'); +var ResetPasswordModal = require('./reset_password_modal.jsx'); + +export default class UserList extends React.Component { + constructor(props) { + super(props); + + this.getTeamProfiles = this.getTeamProfiles.bind(this); + this.getCurrentTeamProfiles = this.getCurrentTeamProfiles.bind(this); + this.doPasswordReset = this.doPasswordReset.bind(this); + this.doPasswordResetDismiss = this.doPasswordResetDismiss.bind(this); + this.doPasswordResetSubmit = this.doPasswordResetSubmit.bind(this); + + this.state = { + teamId: props.team.id, + users: null, + serverError: null, + showPasswordModal: false, + user: null + }; + } + + componentDidMount() { + this.getCurrentTeamProfiles(); + } + + getCurrentTeamProfiles() { + this.getTeamProfiles(this.props.team.id); + } + + // this.setState({ + // teamId: this.state.teamId, + // users: this.state.users, + // serverError: this.state.serverError, + // showPasswordModal: this.state.showPasswordModal, + // user: this.state.user + // }); + + getTeamProfiles(teamId) { + Client.getProfilesForTeam( + teamId, + (users) => { + var memberList = []; + for (var id in users) { + if (users.hasOwnProperty(id)) { + memberList.push(users[id]); + } + } + + memberList.sort((a, b) => { + if (a.username < b.username) { + return -1; + } + + if (a.username > b.username) { + return 1; + } + + return 0; + }); + + this.setState({ + teamId: this.state.teamId, + users: memberList, + serverError: this.state.serverError, + showPasswordModal: this.state.showPasswordModal, + user: this.state.user + }); + }, + (err) => { + this.setState({ + teamId: this.state.teamId, + users: null, + serverError: err.message, + showPasswordModal: this.state.showPasswordModal, + user: this.state.user + }); + } + ); + } + + doPasswordReset(user) { + this.setState({ + teamId: this.state.teamId, + users: this.state.users, + serverError: this.state.serverError, + showPasswordModal: true, + user + }); + } + + doPasswordResetDismiss() { + this.state.showPasswordModal = false; + this.state.user = null; + this.setState({ + teamId: this.state.teamId, + users: this.state.users, + serverError: this.state.serverError, + showPasswordModal: false, + user: null + }); + } + + doPasswordResetSubmit() { + this.setState({ + teamId: this.state.teamId, + users: this.state.users, + serverError: this.state.serverError, + showPasswordModal: false, + user: null + }); + } + + componentWillReceiveProps(newProps) { + this.getTeamProfiles(newProps.team.id); + } + + componentWillUnmount() { + } + + render() { + var serverError = ''; + if (this.state.serverError) { + serverError =
; + } + + if (this.state.users == null) { + return ( +
+

{'Users for ' + this.props.team.name}

+ {serverError} + +
+ ); + } + + var memberList = this.state.users.map((user) => { + return ( + ); + }); + + return ( +
+

{'Users for ' + this.props.team.name + ' (' + this.state.users.length + ')'}

+ {serverError} +
+
+ {memberList} +
+
+ +
+ ); + } +} + +UserList.propTypes = { + team: React.PropTypes.object +}; diff --git a/web/react/components/admin_console/user_item.jsx b/web/react/components/admin_console/user_item.jsx new file mode 100644 index 00000000000..32812e875bf --- /dev/null +++ b/web/react/components/admin_console/user_item.jsx @@ -0,0 +1,266 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var Utils = require('../../utils/utils.jsx'); + +export default class UserItem extends React.Component { + constructor(props) { + super(props); + + this.handleMakeMember = this.handleMakeMember.bind(this); + this.handleMakeActive = this.handleMakeActive.bind(this); + this.handleMakeNotActive = this.handleMakeNotActive.bind(this); + this.handleMakeAdmin = this.handleMakeAdmin.bind(this); + this.handleMakeSystemAdmin = this.handleMakeSystemAdmin.bind(this); + this.handleResetPassword = this.handleResetPassword.bind(this); + + this.state = {}; + } + + handleMakeMember(e) { + e.preventDefault(); + const data = { + user_id: this.props.user.id, + new_roles: '' + }; + + Client.updateRoles(data, + () => { + this.props.refreshProfiles(); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleMakeActive(e) { + e.preventDefault(); + Client.updateActive(this.props.user.id, true, + () => { + this.props.refreshProfiles(); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleMakeNotActive(e) { + e.preventDefault(); + Client.updateActive(this.props.user.id, false, + () => { + this.props.refreshProfiles(); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleMakeAdmin(e) { + e.preventDefault(); + const data = { + user_id: this.props.user.id, + new_roles: 'admin' + }; + + Client.updateRoles(data, + () => { + this.props.refreshProfiles(); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleMakeSystemAdmin(e) { + e.preventDefault(); + const data = { + user_id: this.props.user.id, + new_roles: 'system_admin' + }; + + Client.updateRoles(data, + () => { + this.props.refreshProfiles(); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + handleResetPassword(e) { + e.preventDefault(); + this.props.doPasswordReset(this.props.user); + } + + render() { + let serverError = null; + if (this.state.serverError) { + serverError = ( +
+ +
+ ); + } + + const user = this.props.user; + let currentRoles = 'Member'; + if (user.roles.length > 0) { + if (user.roles.indexOf('system_admin') > -1) { + currentRoles = 'System Admin'; + } else { + currentRoles = user.roles.charAt(0).toUpperCase() + user.roles.slice(1); + } + } + + const email = user.email; + let showMakeMember = user.roles === 'admin' || user.roles === 'system_admin'; + let showMakeAdmin = user.roles === '' || user.roles === 'system_admin'; + let showMakeSystemAdmin = user.roles === '' || user.roles === 'admin'; + let showMakeActive = false; + let showMakeNotActive = user.roles !== 'system_admin'; + + if (user.delete_at > 0) { + currentRoles = 'Inactive'; + currentRoles = 'Inactive'; + showMakeMember = false; + showMakeAdmin = false; + showMakeSystemAdmin = false; + showMakeActive = true; + showMakeNotActive = false; + } + + let makeSystemAdmin = null; + if (showMakeSystemAdmin) { + makeSystemAdmin = ( +
  • + + {'Make System Admin'} + +
  • + ); + } + + let makeAdmin = null; + if (showMakeAdmin) { + makeAdmin = ( +
  • + + {'Make Admin'} + +
  • + ); + } + + let makeMember = null; + if (showMakeMember) { + makeMember = ( +
  • + + {'Make Member'} + +
  • + ); + } + + let makeActive = null; + if (showMakeActive) { + makeActive = ( +
  • + + {'Make Active'} + +
  • + ); + } + + let makeNotActive = null; + if (showMakeNotActive) { + makeNotActive = ( +
  • + + {'Make Inactive'} + +
  • + ); + } + + return ( +
    + + {Utils.getDisplayName(user)} + {email} +
    + + {currentRoles} + + + +
    + {serverError} +
    + ); + } +} + +UserItem.propTypes = { + user: React.PropTypes.object.isRequired, + refreshProfiles: React.PropTypes.func.isRequired, + doPasswordReset: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx index 962ba26ee79..39c86405c9f 100644 --- a/web/react/components/channel_loader.jsx +++ b/web/react/components/channel_loader.jsx @@ -109,6 +109,13 @@ export default class ChannelLoader extends React.Component { $('.modal-body').css('overflow-y', 'auto'); $('.modal-body').css('max-height', $(window).height() * 0.7); }); + + /* Prevent backspace from navigating back a page */ + $(window).on('keydown.preventBackspace', (e) => { + if (e.which === 8 && !$(e.target).is('input, textarea')) { + e.preventDefault(); + } + }); } componentWillUnmount() { clearInterval(this.intervalId); @@ -123,6 +130,8 @@ export default class ChannelLoader extends React.Component { $('body').off('mouseenter mouseleave', '.post.post--comment.same--root'); $('.modal').off('show.bs.modal'); + + $(window).off('keydown.preventBackspace'); } onSocketChange(msg) { if (msg && msg.user_id && msg.user_id !== UserStore.getCurrentId()) { diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx index 83067240dde..9eda68b38b9 100644 --- a/web/react/components/channel_notifications.jsx +++ b/web/react/components/channel_notifications.jsx @@ -163,10 +163,22 @@ export default class ChannelNotifications extends React.Component { }.bind(this); let curChannel = ChannelStore.get(this.state.channelId); - let extraInfo = (These settings will override the global notification settings); + let extraInfo = ( + + These settings will override the global notification settings. +
    + Desktop notifications are available on Firefox, Safari, and Chrome. +
    + ); if (curChannel && curChannel.display_name) { - extraInfo = (These settings will override the global notification settings for the {curChannel.display_name} channel); + extraInfo = ( + + These settings will override the global notification settings for the {curChannel.display_name} channel. +
    + Desktop notifications are available on Firefox, Safari, and Chrome. +
    + ); } return ( diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index c2fc0dcf33e..5097b3aa5af 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -28,6 +28,7 @@ export default class CreateComment extends React.Component { this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); + this.handleTextDrop = this.handleTextDrop.bind(this); this.removePreview = this.removePreview.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.getFileCount = this.getFileCount.bind(this); @@ -42,6 +43,12 @@ export default class CreateComment extends React.Component { submitting: false }; } + componentDidUpdate(prevProps, prevState) { + if (prevState.uploadsInProgress < this.state.uploadsInProgress) { + $('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight); + $('.post-right__scroll').perfectScrollbar('update'); + } + } handleSubmit(e) { e.preventDefault(); @@ -178,6 +185,11 @@ export default class CreateComment extends React.Component { this.setState({serverError: err}); } } + handleTextDrop(text) { + const newText = this.state.messageText + text; + this.handleUserInput(newText); + Utils.setCaretPosition(React.findDOMNode(this.refs.textbox.refs.message), newText.length); + } removePreview(id) { let previews = this.state.previews; let uploadsInProgress = this.state.uploadsInProgress; @@ -264,6 +276,7 @@ export default class CreateComment extends React.Component { onUploadStart={this.handleUploadStart} onFileUpload={this.handleFileUploadComplete} onUploadError={this.handleUploadError} + onTextDrop={this.handleTextDrop} postType='comment' channelId={this.props.channelId} /> diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index abad6015440..0cd14747d5b 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -31,6 +31,7 @@ export default class CreatePost extends React.Component { this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); + this.handleTextDrop = this.handleTextDrop.bind(this); this.removePreview = this.removePreview.bind(this); this.onChange = this.onChange.bind(this); this.getFileCount = this.getFileCount.bind(this); @@ -51,6 +52,12 @@ export default class CreatePost extends React.Component { componentDidUpdate(prevProps, prevState) { if (prevState.previews.length !== this.state.previews.length) { this.resizePostHolder(); + return; + } + + if (prevState.uploadsInProgress !== this.state.uploadsInProgress) { + this.resizePostHolder(); + return; } } getCurrentDraft() { @@ -78,7 +85,7 @@ export default class CreatePost extends React.Component { return; } - let post = {}; + const post = {}; post.filenames = []; post.message = this.state.messageText; @@ -98,20 +105,20 @@ export default class CreatePost extends React.Component { this.state.channelId, post.message, false, - function handleCommandSuccess(data) { + (data) => { PostStore.storeDraft(data.channel_id, null); this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); if (data.goto_location.length > 0) { window.location.href = data.goto_location; } - }.bind(this), - function handleCommandError(err) { - let state = {}; + }, + (err) => { + const state = {}; state.serverError = err.message; state.submitting = false; this.setState(state); - }.bind(this) + } ); } else { post.channel_id = this.state.channelId; @@ -132,10 +139,10 @@ export default class CreatePost extends React.Component { this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); Client.createPost(post, channel, - function handlePostSuccess(data) { + (data) => { AsyncClient.getPosts(); - let member = ChannelStore.getMember(channel.id); + const member = ChannelStore.getMember(channel.id); member.msg_count = channel.total_msg_count; member.last_viewed_at = Date.now(); ChannelStore.setChannelMember(member); @@ -145,8 +152,8 @@ export default class CreatePost extends React.Component { post: data }); }, - function handlePostError(err) { - let state = {}; + (err) => { + const state = {}; if (err.message === 'Invalid RootId parameter') { if ($('#post_deleted').length > 0) { @@ -160,7 +167,7 @@ export default class CreatePost extends React.Component { state.submitting = false; this.setState(state); - }.bind(this) + } ); } } @@ -178,9 +185,9 @@ export default class CreatePost extends React.Component { } } handleUserInput(messageText) { - this.setState({messageText: messageText}); + this.setState({messageText}); - let draft = PostStore.getCurrentDraft(); + const draft = PostStore.getCurrentDraft(); draft.message = messageText; PostStore.storeCurrentDraft(draft); } @@ -190,7 +197,7 @@ export default class CreatePost extends React.Component { $(window).trigger('resize'); } handleUploadStart(clientIds, channelId) { - let draft = PostStore.getDraft(channelId); + const draft = PostStore.getDraft(channelId); draft.uploadsInProgress = draft.uploadsInProgress.concat(clientIds); PostStore.storeDraft(channelId, draft); @@ -198,7 +205,7 @@ export default class CreatePost extends React.Component { this.setState({uploadsInProgress: draft.uploadsInProgress}); } handleFileUploadComplete(filenames, clientIds, channelId) { - let draft = PostStore.getDraft(channelId); + const draft = PostStore.getDraft(channelId); // remove each finished file from uploads for (let i = 0; i < clientIds.length; i++) { @@ -216,7 +223,7 @@ export default class CreatePost extends React.Component { } handleUploadError(err, clientId) { if (clientId !== -1) { - let draft = PostStore.getDraft(this.state.channelId); + const draft = PostStore.getDraft(this.state.channelId); const index = draft.uploadsInProgress.indexOf(clientId); if (index !== -1) { @@ -230,9 +237,14 @@ export default class CreatePost extends React.Component { this.setState({serverError: err}); } } + handleTextDrop(text) { + const newText = this.state.messageText + text; + this.handleUserInput(newText); + Utils.setCaretPosition(React.findDOMNode(this.refs.textbox.refs.message), newText.length); + } removePreview(id) { - let previews = this.state.previews; - let uploadsInProgress = this.state.uploadsInProgress; + const previews = Object.assign([], this.state.previews); + const uploadsInProgress = this.state.uploadsInProgress; // id can either be the path of an uploaded file or the client id of an in progress upload let index = previews.indexOf(id); @@ -247,12 +259,12 @@ export default class CreatePost extends React.Component { } } - let draft = PostStore.getCurrentDraft(); + const draft = PostStore.getCurrentDraft(); draft.previews = previews; draft.uploadsInProgress = uploadsInProgress; PostStore.storeCurrentDraft(draft); - this.setState({previews: previews, uploadsInProgress: uploadsInProgress}); + this.setState({previews, uploadsInProgress}); } componentDidMount() { ChannelStore.addChangeListener(this.onChange); @@ -266,7 +278,7 @@ export default class CreatePost extends React.Component { if (this.state.channelId !== channelId) { const draft = this.getCurrentDraft(); - this.setState({channelId: channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress}); + this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress}); } } getFileCount(channelId) { @@ -334,6 +346,7 @@ export default class CreatePost extends React.Component { onUploadStart={this.handleUploadStart} onFileUpload={this.handleFileUploadComplete} onUploadError={this.handleUploadError} + onTextDrop={this.handleTextDrop} postType='post' channelId='' /> diff --git a/web/react/components/delete_channel_modal.jsx b/web/react/components/delete_channel_modal.jsx index 4efb9cb23f9..44c54db7235 100644 --- a/web/react/components/delete_channel_modal.jsx +++ b/web/react/components/delete_channel_modal.jsx @@ -4,6 +4,7 @@ const Client = require('../utils/client.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); +var TeamStore = require('../stores/team_store.jsx'); export default class DeleteChannelModal extends React.Component { constructor(props) { @@ -24,7 +25,7 @@ export default class DeleteChannelModal extends React.Component { Client.deleteChannel(this.state.channelId, function handleDeleteSuccess() { AsyncClient.getChannels(true); - window.location.href = '/'; + window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/town-square'; }, function handleDeleteError(err) { AsyncClient.dispatchError(err, 'handleDelete'); diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx index 87d94a41dcc..05726e860c7 100644 --- a/web/react/components/error_bar.jsx +++ b/web/react/components/error_bar.jsx @@ -2,10 +2,6 @@ // See License.txt for license information. var ErrorStore = require('../stores/error_store.jsx'); -var utils = require('../utils/utils.jsx'); -var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); -var Constants = require('../utils/constants.jsx'); -var ActionTypes = Constants.ActionTypes; export default class ErrorBar extends React.Component { constructor() { @@ -13,70 +9,79 @@ export default class ErrorBar extends React.Component { this.onErrorChange = this.onErrorChange.bind(this); this.handleClose = this.handleClose.bind(this); + this.prevTimer = null; - this.state = this.getStateFromStores(); - if (this.state.message) { - setTimeout(this.handleClose, 10000); + this.state = ErrorStore.getLastError(); + if (this.state && this.state.message) { + this.prevTimer = setTimeout(this.handleClose, 10000); } } - getStateFromStores() { - var error = ErrorStore.getLastError(); - if (!error || error.message === 'There appears to be a problem with your internet connection') { - return {message: null}; - } - return {message: error.message}; - } componentDidMount() { ErrorStore.addChangeListener(this.onErrorChange); $('body').css('padding-top', $(React.findDOMNode(this)).outerHeight()); - $(window).resize(function onResize() { - if (this.state.message) { + $(window).resize(() => { + if (this.state && this.state.message) { $('body').css('padding-top', $(React.findDOMNode(this)).outerHeight()); } - }.bind(this)); + }); } + componentWillUnmount() { ErrorStore.removeChangeListener(this.onErrorChange); } - onErrorChange() { - var newState = this.getStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { - if (newState.message) { - setTimeout(this.handleClose, 10000); - } + onErrorChange() { + var newState = ErrorStore.getLastError(); + + if (this.prevTimer != null) { + clearInterval(this.prevTimer); + this.prevTimer = null; + } + + if (newState) { this.setState(newState); + this.prevTimer = setTimeout(this.handleClose, 10000); + } else { + this.setState({message: null}); } } + handleClose(e) { if (e) { e.preventDefault(); } - AppDispatcher.handleServerAction({ - type: ActionTypes.RECIEVED_ERROR, - err: null - }); + ErrorStore.storeLastError(null); + ErrorStore.emitChange(); $('body').css('padding-top', '0'); } + render() { - if (this.state.message) { - return ( -
    - {this.state.message} - - × - -
    - ); + if (!this.state) { + return
    ; } - return
    ; + if (!this.state.message) { + return
    ; + } + + if (this.state.connErrorCount < 7) { + return
    ; + } + + return ( +
    + {this.state.message} + + × + +
    + ); } } diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx index c9aa06a978d..888f24aa5c1 100644 --- a/web/react/components/file_attachment.jsx +++ b/web/react/components/file_attachment.jsx @@ -143,10 +143,7 @@ export default class FileAttachment extends React.Component { > this.props.handleImageClick(this.props.index)} > {thumbnail} @@ -187,9 +184,6 @@ FileAttachment.propTypes = { // the index of this attachment preview in the parent FileAttachmentList index: React.PropTypes.number.isRequired, - // the identifier of the modal dialog used to preview files - modalId: React.PropTypes.string.isRequired, - - // handler for when the thumbnail is clicked + // handler for when the thumbnail is clicked passed the index above handleImageClick: React.PropTypes.func }; diff --git a/web/react/components/file_attachment_list.jsx b/web/react/components/file_attachment_list.jsx index abe72089aee..212d4a9580f 100644 --- a/web/react/components/file_attachment_list.jsx +++ b/web/react/components/file_attachment_list.jsx @@ -11,23 +11,21 @@ export default class FileAttachmentList extends React.Component { this.handleImageClick = this.handleImageClick.bind(this); - this.state = {startImgId: 0}; + this.state = {showPreviewModal: false, startImgId: 0}; } - handleImageClick(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'), 10)}); + handleImageClick(indexClicked) { + this.setState({showPreviewModal: true, startImgId: indexClicked}); } render() { var filenames = this.props.filenames; - var modalId = this.props.modalId; var postFiles = []; for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { postFiles.push( ); @@ -39,9 +37,10 @@ export default class FileAttachmentList extends React.Component { {postFiles}
    this.setState({showPreviewModal: false})} channelId={this.props.channelId} userId={this.props.userId} - modalId={modalId} startId={this.state.startImgId} filenames={filenames} /> @@ -55,9 +54,6 @@ FileAttachmentList.propTypes = { // a list of file pathes displayed by this filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, - // the identifier of the modal dialog used to preview files - modalId: React.PropTypes.string.isRequired, - // the channel that this is part of channelId: React.PropTypes.string, diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx index 3cb28417172..3dc4e5de2b5 100644 --- a/web/react/components/file_upload.jsx +++ b/web/react/components/file_upload.jsx @@ -110,7 +110,7 @@ export default class FileUpload extends React.Component { if (typeof files !== 'string' && files.length) { this.uploadFiles(files); } else { - this.props.onUploadError('Invalid file upload', -1); + this.props.onTextDrop(e.originalEvent.dataTransfer.getData('Text')); } } @@ -266,6 +266,7 @@ FileUpload.propTypes = { getFileCount: React.PropTypes.func, onFileUpload: React.PropTypes.func, onUploadStart: React.PropTypes.func, + onTextDrop: React.PropTypes.func, channelId: React.PropTypes.string, postType: React.PropTypes.string }; diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index da9874b0b13..bdb50cd9ea0 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -262,7 +262,7 @@ export default class Navbar extends React.Component { return (
    {channelTitle} diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index dbbcdc40970..6e98e4aba4b 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -141,7 +141,6 @@ export default class PostBody extends React.Component { fileAttachmentHolder = ( diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index d2a0a403504..824e7ef3974 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -5,6 +5,8 @@ var UserStore = require('../stores/user_store.jsx'); var utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); +var Tooltip = ReactBootstrap.Tooltip; +var OverlayTrigger = ReactBootstrap.OverlayTrigger; export default class PostInfo extends React.Component { constructor(props) { @@ -148,15 +150,19 @@ export default class PostInfo extends React.Component { var dropdown = this.createDropdown(); + let tooltip = {utils.displayDate(post.create_at)} at ${utils.displayTime(post.create_at)}; + return (