summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/license.go32
-rw-r--r--api/post.go580
-rw-r--r--api/post_test.go95
-rw-r--r--api/web_team_hub.go3
-rw-r--r--i18n/en.json54
-rw-r--r--i18n/es.json22
-rw-r--r--mattermost.go22
-rw-r--r--model/license.go26
-rw-r--r--model/message.go1
-rw-r--r--model/post.go2
-rw-r--r--model/system.go1
-rw-r--r--store/sql_license_store.go83
-rw-r--r--store/sql_license_store_test.go43
-rw-r--r--store/sql_store.go8
-rw-r--r--store/sql_system_store.go26
-rw-r--r--store/sql_system_store_test.go16
-rw-r--r--store/store.go9
-rw-r--r--utils/license.go37
-rw-r--r--web/react/components/access_history_modal.jsx3
-rw-r--r--web/react/components/admin_console/admin_controller.jsx2
-rw-r--r--web/react/components/admin_console/admin_sidebar.jsx4
-rw-r--r--web/react/components/admin_console/audits.jsx5
-rw-r--r--web/react/components/admin_console/license_settings.jsx18
-rw-r--r--web/react/components/audit_table.jsx655
-rw-r--r--web/react/components/create_comment.jsx15
-rw-r--r--web/react/components/create_post.jsx55
-rw-r--r--web/react/components/delete_post_modal.jsx2
-rw-r--r--web/react/components/file_upload.jsx2
-rw-r--r--web/react/components/post_body.jsx30
-rw-r--r--web/react/components/post_info.jsx25
-rw-r--r--web/react/components/textbox.jsx12
-rw-r--r--web/react/components/user_settings/manage_command_hooks.jsx29
-rw-r--r--web/react/components/user_settings/user_settings_notifications.jsx6
-rw-r--r--web/react/dispatcher/event_helpers.jsx25
-rw-r--r--web/react/stores/post_store.jsx92
-rw-r--r--web/react/stores/socket_store.jsx27
-rw-r--r--web/react/utils/constants.jsx5
-rw-r--r--web/react/utils/utils.jsx4
-rw-r--r--web/sass-files/sass/partials/_post.scss9
-rw-r--r--web/static/i18n/en.json178
-rw-r--r--web/static/i18n/es.json54
41 files changed, 1429 insertions, 888 deletions
diff --git a/api/license.go b/api/license.go
index 4077c0e46..23e7946c8 100644
--- a/api/license.go
+++ b/api/license.go
@@ -81,9 +81,24 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if err := writeFileLocally(data, utils.LicenseLocation()); err != nil {
- c.LogAudit("failed - could not save license file")
- c.Err = model.NewLocAppError("addLicense", "api.license.add_license.save.app_error", nil, "path="+utils.LicenseLocation())
+ record := &model.LicenseRecord{}
+ record.Id = license.Id
+ record.Bytes = string(data)
+ rchan := Srv.Store.License().Save(record)
+
+ sysVar := &model.System{}
+ sysVar.Name = model.SYSTEM_ACTIVE_LICENSE_ID
+ sysVar.Value = license.Id
+ schan := Srv.Store.System().SaveOrUpdate(sysVar)
+
+ if result := <-rchan; result.Err != nil {
+ c.Err = model.NewLocAppError("addLicense", "api.license.add_license.save.app_error", nil, "err="+result.Err.Error())
+ utils.RemoveLicense()
+ return
+ }
+
+ if result := <-schan; result.Err != nil {
+ c.Err = model.NewLocAppError("addLicense", "api.license.add_license.save_active.app_error", nil, "")
utils.RemoveLicense()
return
}
@@ -100,9 +115,14 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) {
func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) {
c.LogAudit("")
- if ok := utils.RemoveLicense(); !ok {
- c.LogAudit("failed - could not remove license file")
- c.Err = model.NewLocAppError("removeLicense", "api.license.remove_license.remove.app_error", nil, "")
+ utils.RemoveLicense()
+
+ sysVar := &model.System{}
+ sysVar.Name = model.SYSTEM_ACTIVE_LICENSE_ID
+ sysVar.Value = ""
+
+ if result := <-Srv.Store.System().Update(sysVar); result.Err != nil {
+ c.Err = model.NewLocAppError("removeLicense", "api.license.remove_license.update.app_error", nil, "")
return
}
diff --git a/api/post.go b/api/post.go
index e8345b5e5..c17da262f 100644
--- a/api/post.go
+++ b/api/post.go
@@ -15,6 +15,7 @@ import (
"net/url"
"path/filepath"
"regexp"
+ "sort"
"strconv"
"strings"
"time"
@@ -231,6 +232,8 @@ func handlePostEventsAndForget(c *Context, post *model.Post, triggerWebhooks boo
tchan := Srv.Store.Team().Get(c.Session.TeamId)
cchan := Srv.Store.Channel().Get(post.ChannelId)
uchan := Srv.Store.User().Get(post.UserId)
+ pchan := Srv.Store.User().GetProfiles(c.Session.TeamId)
+ mchan := Srv.Store.Channel().GetMembers(post.ChannelId)
var team *model.Team
if result := <-tchan; result.Err != nil {
@@ -248,7 +251,24 @@ func handlePostEventsAndForget(c *Context, post *model.Post, triggerWebhooks boo
channel = result.Data.(*model.Channel)
}
- sendNotificationsAndForget(c, post, team, channel)
+ var profiles map[string]*model.User
+ if result := <-pchan; result.Err != nil {
+ l4g.Error(utils.T("api.post.handle_post_events_and_forget.profiles.error"), c.Session.TeamId, result.Err)
+ return
+ } else {
+ profiles = result.Data.(map[string]*model.User)
+ }
+
+ var members []model.ChannelMember
+ if result := <-mchan; result.Err != nil {
+ l4g.Error(utils.T("api.post.handle_post_events_and_forget.members.error"), post.ChannelId, result.Err)
+ return
+ } else {
+ members = result.Data.([]model.ChannelMember)
+ }
+
+ go sendNotifications(c, post, team, channel, profiles, members)
+ go checkForOutOfChannelMentions(c, post, channel, profiles, members)
var user *model.User
if result := <-uchan; result.Err != nil {
@@ -413,311 +433,290 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team
}
-func sendNotificationsAndForget(c *Context, post *model.Post, team *model.Team, channel *model.Channel) {
+func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *model.Channel, profileMap map[string]*model.User, members []model.ChannelMember) {
+ var channelName string
+ var bodyText string
+ var subjectText string
- go func() {
- // Get a list of user names (to be used as keywords) and ids for the given team
- uchan := Srv.Store.User().GetProfiles(c.Session.TeamId)
- echan := Srv.Store.Channel().GetMembers(post.ChannelId)
+ var mentionedUsers []string
- var channelName string
- var bodyText string
- var subjectText string
+ if _, ok := profileMap[post.UserId]; !ok {
+ l4g.Error(utils.T("api.post.send_notifications_and_forget.user_id.error"), post.UserId)
+ return
+ }
+ senderName := profileMap[post.UserId].Username
- var mentionedUsers []string
+ toEmailMap := make(map[string]bool)
- if result := <-uchan; result.Err != nil {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.retrive_profiles.error"), c.Session.TeamId, result.Err)
- return
+ if channel.Type == model.CHANNEL_DIRECT {
+
+ var otherUserId string
+ if userIds := strings.Split(channel.Name, "__"); userIds[0] == post.UserId {
+ otherUserId = userIds[1]
+ channelName = profileMap[userIds[1]].Username
} else {
- profileMap := result.Data.(map[string]*model.User)
+ otherUserId = userIds[0]
+ channelName = profileMap[userIds[0]].Username
+ }
- if _, ok := profileMap[post.UserId]; !ok {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.user_id.error"), post.UserId)
- return
- }
- senderName := profileMap[post.UserId].Username
+ otherUser := profileMap[otherUserId]
+ sendEmail := true
+ if _, ok := otherUser.NotifyProps["email"]; ok && otherUser.NotifyProps["email"] == "false" {
+ sendEmail = false
+ }
+ if sendEmail && (otherUser.IsOffline() || otherUser.IsAway()) {
+ toEmailMap[otherUserId] = true
+ }
- toEmailMap := make(map[string]bool)
+ } else {
+ // Find out who is a member of the channel, only keep those profiles
+ tempProfileMap := make(map[string]*model.User)
+ for _, member := range members {
+ tempProfileMap[member.UserId] = profileMap[member.UserId]
+ }
- if channel.Type == model.CHANNEL_DIRECT {
+ profileMap = tempProfileMap
- var otherUserId string
- if userIds := strings.Split(channel.Name, "__"); userIds[0] == post.UserId {
- otherUserId = userIds[1]
- channelName = profileMap[userIds[1]].Username
- } else {
- otherUserId = userIds[0]
- channelName = profileMap[userIds[0]].Username
- }
+ // Build map for keywords
+ keywordMap := make(map[string][]string)
+ for _, profile := range profileMap {
+ if len(profile.NotifyProps["mention_keys"]) > 0 {
- otherUser := profileMap[otherUserId]
- sendEmail := true
- if _, ok := otherUser.NotifyProps["email"]; ok && otherUser.NotifyProps["email"] == "false" {
- sendEmail = false
+ // Add all the user's mention keys
+ splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",")
+ for _, k := range splitKeys {
+ keywordMap[k] = append(keywordMap[strings.ToLower(k)], profile.Id)
}
- if sendEmail && (otherUser.IsOffline() || otherUser.IsAway()) {
- toEmailMap[otherUserId] = true
- }
-
- } else {
-
- // Find out who is a member of the channel, only keep those profiles
- if eResult := <-echan; eResult.Err != nil {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.members.error"), post.ChannelId, eResult.Err.Message)
- return
- } else {
- tempProfileMap := make(map[string]*model.User)
- members := eResult.Data.([]model.ChannelMember)
- for _, member := range members {
- tempProfileMap[member.UserId] = profileMap[member.UserId]
- }
+ }
- profileMap = tempProfileMap
- }
+ // If turned on, add the user's case sensitive first name
+ if profile.NotifyProps["first_name"] == "true" {
+ keywordMap[profile.FirstName] = append(keywordMap[profile.FirstName], profile.Id)
+ }
- // Build map for keywords
- keywordMap := make(map[string][]string)
- for _, profile := range profileMap {
- if len(profile.NotifyProps["mention_keys"]) > 0 {
+ // Add @all to keywords if user has them turned on
+ // if profile.NotifyProps["all"] == "true" {
+ // keywordMap["@all"] = append(keywordMap["@all"], profile.Id)
+ // }
- // Add all the user's mention keys
- splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",")
- for _, k := range splitKeys {
- keywordMap[k] = append(keywordMap[strings.ToLower(k)], profile.Id)
- }
- }
+ // Add @channel to keywords if user has them turned on
+ if profile.NotifyProps["channel"] == "true" {
+ keywordMap["@channel"] = append(keywordMap["@channel"], profile.Id)
+ }
+ }
- // If turned on, add the user's case sensitive first name
- if profile.NotifyProps["first_name"] == "true" {
- keywordMap[profile.FirstName] = append(keywordMap[profile.FirstName], profile.Id)
- }
+ // Build a map as a list of unique user_ids that are mentioned in this post
+ splitF := func(c rune) bool {
+ return model.SplitRunes[c]
+ }
+ splitMessage := strings.Fields(post.Message)
+ for _, word := range splitMessage {
+ var userIds []string
- // Add @all to keywords if user has them turned on
- // if profile.NotifyProps["all"] == "true" {
- // keywordMap["@all"] = append(keywordMap["@all"], profile.Id)
- // }
+ // Non-case-sensitive check for regular keys
+ if ids, match := keywordMap[strings.ToLower(word)]; match {
+ userIds = append(userIds, ids...)
+ }
- // Add @channel to keywords if user has them turned on
- if profile.NotifyProps["channel"] == "true" {
- keywordMap["@channel"] = append(keywordMap["@channel"], profile.Id)
- }
- }
+ // Case-sensitive check for first name
+ if ids, match := keywordMap[word]; match {
+ userIds = append(userIds, ids...)
+ }
- // Build a map as a list of unique user_ids that are mentioned in this post
- splitF := func(c rune) bool {
- return model.SplitRunes[c]
- }
- splitMessage := strings.Fields(post.Message)
- for _, word := range splitMessage {
- var userIds []string
+ if len(userIds) == 0 {
+ // No matches were found with the string split just on whitespace so try further splitting
+ // the message on punctuation
+ splitWords := strings.FieldsFunc(word, splitF)
+ for _, splitWord := range splitWords {
// Non-case-sensitive check for regular keys
- if ids, match := keywordMap[strings.ToLower(word)]; match {
+ if ids, match := keywordMap[strings.ToLower(splitWord)]; match {
userIds = append(userIds, ids...)
}
// Case-sensitive check for first name
- if ids, match := keywordMap[word]; match {
+ if ids, match := keywordMap[splitWord]; match {
userIds = append(userIds, ids...)
}
-
- if len(userIds) == 0 {
- // No matches were found with the string split just on whitespace so try further splitting
- // the message on punctuation
- splitWords := strings.FieldsFunc(word, splitF)
-
- for _, splitWord := range splitWords {
- // Non-case-sensitive check for regular keys
- if ids, match := keywordMap[strings.ToLower(splitWord)]; match {
- userIds = append(userIds, ids...)
- }
-
- // Case-sensitive check for first name
- if ids, match := keywordMap[splitWord]; match {
- userIds = append(userIds, ids...)
- }
- }
- }
-
- for _, userId := range userIds {
- if post.UserId == userId {
- continue
- }
- sendEmail := true
- if _, ok := profileMap[userId].NotifyProps["email"]; ok && profileMap[userId].NotifyProps["email"] == "false" {
- sendEmail = false
- }
- if sendEmail && (profileMap[userId].IsAway() || profileMap[userId].IsOffline()) {
- toEmailMap[userId] = true
- } else {
- toEmailMap[userId] = false
- }
- }
}
+ }
- for id := range toEmailMap {
- updateMentionCountAndForget(post.ChannelId, id)
+ for _, userId := range userIds {
+ if post.UserId == userId {
+ continue
+ }
+ sendEmail := true
+ if _, ok := profileMap[userId].NotifyProps["email"]; ok && profileMap[userId].NotifyProps["email"] == "false" {
+ sendEmail = false
+ }
+ if sendEmail && (profileMap[userId].IsAway() || profileMap[userId].IsOffline()) {
+ toEmailMap[userId] = true
+ } else {
+ toEmailMap[userId] = false
}
}
+ }
- if len(toEmailMap) != 0 {
- mentionedUsers = make([]string, 0, len(toEmailMap))
- for k := range toEmailMap {
- mentionedUsers = append(mentionedUsers, k)
- }
+ for id := range toEmailMap {
+ updateMentionCountAndForget(post.ChannelId, id)
+ }
+ }
- teamURL := c.GetSiteURL() + "/" + team.Name
+ if len(toEmailMap) != 0 {
+ mentionedUsers = make([]string, 0, len(toEmailMap))
+ for k := range toEmailMap {
+ mentionedUsers = append(mentionedUsers, k)
+ }
- // Build and send the emails
- tm := time.Unix(post.CreateAt/1000, 0)
+ teamURL := c.GetSiteURL() + "/" + team.Name
- for id, doSend := range toEmailMap {
+ // Build and send the emails
+ tm := time.Unix(post.CreateAt/1000, 0)
- if !doSend {
- continue
- }
+ for id, doSend := range toEmailMap {
- // skip if inactive
- if profileMap[id].DeleteAt > 0 {
- continue
- }
+ if !doSend {
+ continue
+ }
- userLocale := utils.GetUserTranslations(profileMap[id].Locale)
+ // skip if inactive
+ if profileMap[id].DeleteAt > 0 {
+ continue
+ }
- if channel.Type == model.CHANNEL_DIRECT {
- bodyText = userLocale("api.post.send_notifications_and_forget.message_body")
- subjectText = userLocale("api.post.send_notifications_and_forget.message_subject")
- } else {
- bodyText = userLocale("api.post.send_notifications_and_forget.mention_body")
- subjectText = userLocale("api.post.send_notifications_and_forget.mention_subject")
- channelName = channel.DisplayName
- }
+ userLocale := utils.GetUserTranslations(profileMap[id].Locale)
- month := userLocale(tm.Month().String())
- day := fmt.Sprintf("%d", tm.Day())
- year := fmt.Sprintf("%d", tm.Year())
- zone, _ := tm.Zone()
-
- subjectPage := NewServerTemplatePage("post_subject", c.Locale)
- subjectPage.Props["Subject"] = userLocale("api.templates.post_subject",
- map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName,
- "Month": month[:3], "Day": day, "Year": year})
-
- bodyPage := NewServerTemplatePage("post_body", c.Locale)
- bodyPage.Props["SiteURL"] = c.GetSiteURL()
- bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message)
- bodyPage.Props["TeamLink"] = teamURL + "/channels/" + channel.Name
- bodyPage.Props["BodyText"] = bodyText
- bodyPage.Props["Button"] = userLocale("api.templates.post_body.button")
- bodyPage.Html["Info"] = template.HTML(userLocale("api.templates.post_body.info",
- map[string]interface{}{"ChannelName": channelName, "SenderName": senderName,
- "Hour": fmt.Sprintf("%02d", tm.Hour()), "Minute": fmt.Sprintf("%02d", tm.Minute()),
- "TimeZone": zone, "Month": month, "Day": day}))
-
- // attempt to fill in a message body if the post doesn't have any text
- if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 {
- // extract the filenames from their paths and determine what type of files are attached
- filenames := make([]string, len(post.Filenames))
- onlyImages := true
- for i, filename := range post.Filenames {
- var err error
- if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
- // this should never error since filepath was escaped using url.QueryEscape
- filenames[i] = filepath.Base(filename)
- }
+ if channel.Type == model.CHANNEL_DIRECT {
+ bodyText = userLocale("api.post.send_notifications_and_forget.message_body")
+ subjectText = userLocale("api.post.send_notifications_and_forget.message_subject")
+ } else {
+ bodyText = userLocale("api.post.send_notifications_and_forget.mention_body")
+ subjectText = userLocale("api.post.send_notifications_and_forget.mention_subject")
+ channelName = channel.DisplayName
+ }
- ext := filepath.Ext(filename)
- onlyImages = onlyImages && model.IsFileExtImage(ext)
- }
- filenamesString := strings.Join(filenames, ", ")
+ month := userLocale(tm.Month().String())
+ day := fmt.Sprintf("%d", tm.Day())
+ year := fmt.Sprintf("%d", tm.Year())
+ zone, _ := tm.Zone()
+
+ subjectPage := NewServerTemplatePage("post_subject", c.Locale)
+ subjectPage.Props["Subject"] = userLocale("api.templates.post_subject",
+ map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName,
+ "Month": month[:3], "Day": day, "Year": year})
+
+ bodyPage := NewServerTemplatePage("post_body", c.Locale)
+ bodyPage.Props["SiteURL"] = c.GetSiteURL()
+ bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message)
+ bodyPage.Props["TeamLink"] = teamURL + "/channels/" + channel.Name
+ bodyPage.Props["BodyText"] = bodyText
+ bodyPage.Props["Button"] = userLocale("api.templates.post_body.button")
+ bodyPage.Html["Info"] = template.HTML(userLocale("api.templates.post_body.info",
+ map[string]interface{}{"ChannelName": channelName, "SenderName": senderName,
+ "Hour": fmt.Sprintf("%02d", tm.Hour()), "Minute": fmt.Sprintf("%02d", tm.Minute()),
+ "TimeZone": zone, "Month": month, "Day": day}))
+
+ // attempt to fill in a message body if the post doesn't have any text
+ if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 {
+ // extract the filenames from their paths and determine what type of files are attached
+ filenames := make([]string, len(post.Filenames))
+ onlyImages := true
+ for i, filename := range post.Filenames {
+ var err error
+ if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
+ // this should never error since filepath was escaped using url.QueryEscape
+ filenames[i] = filepath.Base(filename)
+ }
- var attachmentPrefix string
- if onlyImages {
- attachmentPrefix = "Image"
- } else {
- attachmentPrefix = "File"
- }
- if len(post.Filenames) > 1 {
- attachmentPrefix += "s"
- }
+ ext := filepath.Ext(filename)
+ onlyImages = onlyImages && model.IsFileExtImage(ext)
+ }
+ filenamesString := strings.Join(filenames, ", ")
- bodyPage.Props["PostMessage"] = userLocale("api.post.send_notifications_and_forget.sent",
- map[string]interface{}{"Prefix": attachmentPrefix, "Filenames": filenamesString})
- }
+ var attachmentPrefix string
+ if onlyImages {
+ attachmentPrefix = "Image"
+ } else {
+ attachmentPrefix = "File"
+ }
+ if len(post.Filenames) > 1 {
+ attachmentPrefix += "s"
+ }
- if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), profileMap[id].Email, err)
- }
+ bodyPage.Props["PostMessage"] = userLocale("api.post.send_notifications_and_forget.sent",
+ map[string]interface{}{"Prefix": attachmentPrefix, "Filenames": filenamesString})
+ }
- if *utils.Cfg.EmailSettings.SendPushNotifications {
- sessionChan := Srv.Store.Session().GetSessions(id)
- if result := <-sessionChan; result.Err != nil {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.sessions.error"), id, result.Err)
- } else {
- sessions := result.Data.([]*model.Session)
- alreadySeen := make(map[string]string)
-
- for _, session := range sessions {
- if len(session.DeviceId) > 0 && alreadySeen[session.DeviceId] == "" &&
- (strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") || strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":")) {
- alreadySeen[session.DeviceId] = session.DeviceId
-
- msg := model.PushNotification{}
- msg.Badge = 1
- msg.ServerId = utils.CfgDiagnosticId
-
- if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") {
- msg.Platform = model.PUSH_NOTIFY_APPLE
- msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":")
- } else if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":") {
- msg.Platform = model.PUSH_NOTIFY_ANDROID
- msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":")
- }
+ if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil {
+ l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), profileMap[id].Email, err)
+ }
- if channel.Type == model.CHANNEL_DIRECT {
- msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message")
- } else {
- msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName
- }
+ if *utils.Cfg.EmailSettings.SendPushNotifications {
+ sessionChan := Srv.Store.Session().GetSessions(id)
+ if result := <-sessionChan; result.Err != nil {
+ l4g.Error(utils.T("api.post.send_notifications_and_forget.sessions.error"), id, result.Err)
+ } else {
+ sessions := result.Data.([]*model.Session)
+ alreadySeen := make(map[string]string)
+
+ for _, session := range sessions {
+ if len(session.DeviceId) > 0 && alreadySeen[session.DeviceId] == "" &&
+ (strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") || strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":")) {
+ alreadySeen[session.DeviceId] = session.DeviceId
+
+ msg := model.PushNotification{}
+ msg.Badge = 1
+ msg.ServerId = utils.CfgDiagnosticId
+
+ if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") {
+ msg.Platform = model.PUSH_NOTIFY_APPLE
+ msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":")
+ } else if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":") {
+ msg.Platform = model.PUSH_NOTIFY_ANDROID
+ msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":")
+ }
- httpClient := http.Client{}
- request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+"/api/v1/send_push", strings.NewReader(msg.ToJson()))
+ if channel.Type == model.CHANNEL_DIRECT {
+ msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message")
+ } else {
+ msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName
+ }
- l4g.Debug(utils.T("api.post.send_notifications_and_forget.push_notification.debug"), msg.DeviceId, msg.Message)
- if _, err := httpClient.Do(request); err != nil {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.push_notification.error"), id, err)
- }
- }
+ httpClient := http.Client{}
+ request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+"/api/v1/send_push", strings.NewReader(msg.ToJson()))
+
+ l4g.Debug(utils.T("api.post.send_notifications_and_forget.push_notification.debug"), msg.DeviceId, msg.Message)
+ if _, err := httpClient.Do(request); err != nil {
+ l4g.Error(utils.T("api.post.send_notifications_and_forget.push_notification.error"), id, err)
}
}
}
}
}
}
+ }
- message := model.NewMessage(c.Session.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED)
- message.Add("post", post.ToJson())
- message.Add("channel_type", channel.Type)
+ message := model.NewMessage(c.Session.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED)
+ message.Add("post", post.ToJson())
+ message.Add("channel_type", channel.Type)
- if len(post.Filenames) != 0 {
- message.Add("otherFile", "true")
+ if len(post.Filenames) != 0 {
+ message.Add("otherFile", "true")
- for _, filename := range post.Filenames {
- ext := filepath.Ext(filename)
- if model.IsFileExtImage(ext) {
- message.Add("image", "true")
- break
- }
+ for _, filename := range post.Filenames {
+ ext := filepath.Ext(filename)
+ if model.IsFileExtImage(ext) {
+ message.Add("image", "true")
+ break
}
}
+ }
- if len(mentionedUsers) != 0 {
- message.Add("mentions", model.ArrayToJson(mentionedUsers))
- }
+ if len(mentionedUsers) != 0 {
+ message.Add("mentions", model.ArrayToJson(mentionedUsers))
+ }
- PublishAndForget(message)
- }()
+ PublishAndForget(message)
}
func updateMentionCountAndForget(channelId, userId string) {
@@ -728,6 +727,95 @@ func updateMentionCountAndForget(channelId, userId string) {
}()
}
+func checkForOutOfChannelMentions(c *Context, post *model.Post, channel *model.Channel, allProfiles map[string]*model.User, members []model.ChannelMember) {
+ // don't check for out of channel mentions in direct channels
+ if channel.Type == model.CHANNEL_DIRECT {
+ return
+ }
+
+ mentioned := getOutOfChannelMentions(post, allProfiles, members)
+ if len(mentioned) == 0 {
+ return
+ }
+
+ usernames := make([]string, len(mentioned))
+ for i, user := range mentioned {
+ usernames[i] = user.Username
+ }
+ sort.Strings(usernames)
+
+ var message string
+ if len(usernames) == 1 {
+ message = c.T("api.post.check_for_out_of_channel_mentions.message.one", map[string]interface{}{
+ "Username": usernames[0],
+ })
+ } else {
+ message = c.T("api.post.check_for_out_of_channel_mentions.message.multiple", map[string]interface{}{
+ "Usernames": strings.Join(usernames[:len(usernames)-1], ", "),
+ "LastUsername": usernames[len(usernames)-1],
+ })
+ }
+
+ SendEphemeralPost(
+ c.Session.TeamId,
+ post.UserId,
+ &model.Post{
+ ChannelId: post.ChannelId,
+ Message: message,
+ CreateAt: post.CreateAt + 1,
+ },
+ )
+}
+
+// Gets a list of users that were mentioned in a given post that aren't in the channel that the post was made in
+func getOutOfChannelMentions(post *model.Post, allProfiles map[string]*model.User, members []model.ChannelMember) []*model.User {
+ // copy the profiles map since we'll be removing items from it
+ profiles := make(map[string]*model.User)
+ for id, profile := range allProfiles {
+ profiles[id] = profile
+ }
+
+ // only keep profiles which aren't in the current channel
+ for _, member := range members {
+ delete(profiles, member.UserId)
+ }
+
+ var mentioned []*model.User
+
+ for _, profile := range profiles {
+ if pattern, err := regexp.Compile(`(\W|^)@` + regexp.QuoteMeta(profile.Username) + `(\W|$)`); err != nil {
+ l4g.Error(utils.T("api.post.get_out_of_channel_mentions.regex.error"), profile.Id, err)
+ } else if pattern.MatchString(post.Message) {
+ mentioned = append(mentioned, profile)
+ }
+ }
+
+ return mentioned
+}
+
+func SendEphemeralPost(teamId, userId string, post *model.Post) {
+ post.Type = model.POST_EPHEMERAL
+
+ // fill in fields which haven't been specified which have sensible defaults
+ if post.Id == "" {
+ post.Id = model.NewId()
+ }
+ if post.CreateAt == 0 {
+ post.CreateAt = model.GetMillis()
+ }
+ if post.Props == nil {
+ post.Props = model.StringInterface{}
+ }
+ if post.Filenames == nil {
+ post.Filenames = []string{}
+ }
+
+ message := model.NewMessage(teamId, post.ChannelId, userId, model.ACTION_EPHEMERAL_MESSAGE)
+ message.Add("post", post.ToJson())
+
+ PublishAndForget(message)
+}
+
func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
post := model.PostFromJson(r.Body)
diff --git a/api/post_test.go b/api/post_test.go
index 1a9fd2579..027043766 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -8,6 +8,7 @@ import (
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
"net/http"
+ "strings"
"testing"
"time"
)
@@ -857,3 +858,97 @@ func TestMakeDirectChannelVisible(t *testing.T) {
t.Fatal("Failed to set direct channel to be visible for user2")
}
}
+
+func TestGetOutOfChannelMentions(t *testing.T) {
+ Setup()
+
+ team1 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Type: model.TEAM_OPEN}
+ team1 = Client.Must(Client.CreateTeam(team1)).Data.(*model.Team)
+
+ user1 := &model.User{TeamId: team1.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user1"}
+ user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user1.Id))
+
+ user2 := &model.User{TeamId: team1.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user2"}
+ user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user2.Id))
+
+ user3 := &model.User{TeamId: team1.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user3"}
+ user3 = Client.Must(Client.CreateUser(user3, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user3.Id))
+
+ Client.Must(Client.LoginByEmail(team1.Name, user1.Email, "pwd"))
+
+ channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team1.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ var allProfiles map[string]*model.User
+ if result := <-Srv.Store.User().GetProfiles(team1.Id); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ allProfiles = result.Data.(map[string]*model.User)
+ }
+
+ var members []model.ChannelMember
+ if result := <-Srv.Store.Channel().GetMembers(channel1.Id); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ members = result.Data.([]model.ChannelMember)
+ }
+
+ // test a post that doesn't @mention anybody
+ post1 := &model.Post{ChannelId: channel1.Id, Message: "user1 user2 user3"}
+ if mentioned := getOutOfChannelMentions(post1, allProfiles, members); len(mentioned) != 0 {
+ t.Fatalf("getOutOfChannelMentions returned %v when no users were mentioned", mentioned)
+ }
+
+ // test a post that @mentions someone in the channel
+ post2 := &model.Post{ChannelId: channel1.Id, Message: "@user1 is user1"}
+ if mentioned := getOutOfChannelMentions(post2, allProfiles, members); len(mentioned) != 0 {
+ t.Fatalf("getOutOfChannelMentions returned %v when only users in the channel were mentioned", mentioned)
+ }
+
+ // test a post that @mentions someone not in the channel
+ post3 := &model.Post{ChannelId: channel1.Id, Message: "@user2 and @user3 aren't in the channel"}
+ if mentioned := getOutOfChannelMentions(post3, allProfiles, members); len(mentioned) != 2 || (mentioned[0].Id != user2.Id && mentioned[0].Id != user3.Id) || (mentioned[1].Id != user2.Id && mentioned[1].Id != user3.Id) {
+ t.Fatalf("getOutOfChannelMentions returned %v when two users outside the channel were mentioned", mentioned)
+ }
+
+ // test a post that @mentions someone not in the channel as well as someone in the channel
+ post4 := &model.Post{ChannelId: channel1.Id, Message: "@user2 and @user1 might be in the channel"}
+ if mentioned := getOutOfChannelMentions(post4, allProfiles, members); len(mentioned) != 1 || mentioned[0].Id != user2.Id {
+ t.Fatalf("getOutOfChannelMentions returned %v when someone in the channel and someone outside the channel were mentioned", mentioned)
+ }
+
+ Client.Must(Client.Logout())
+
+ team2 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Type: model.TEAM_OPEN}
+ team2 = Client.Must(Client.CreateTeam(team2)).Data.(*model.Team)
+
+ user4 := &model.User{TeamId: team2.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user4"}
+ user4 = Client.Must(Client.CreateUser(user4, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user4.Id))
+
+ Client.Must(Client.LoginByEmail(team2.Name, user4.Email, "pwd"))
+
+ channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team2.Id}
+ channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
+
+ if result := <-Srv.Store.User().GetProfiles(team2.Id); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ allProfiles = result.Data.(map[string]*model.User)
+ }
+
+ if result := <-Srv.Store.Channel().GetMembers(channel2.Id); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ members = result.Data.([]model.ChannelMember)
+ }
+
+ // test a post that @mentions someone on a different team
+ post5 := &model.Post{ChannelId: channel2.Id, Message: "@user2 and @user3 might be in the channel"}
+ if mentioned := getOutOfChannelMentions(post5, allProfiles, members); len(mentioned) != 0 {
+ t.Fatalf("getOutOfChannelMentions returned %v when two users on a different team were mentioned", mentioned)
+ }
+}
diff --git a/api/web_team_hub.go b/api/web_team_hub.go
index 55300c828..9d1c56f15 100644
--- a/api/web_team_hub.go
+++ b/api/web_team_hub.go
@@ -101,6 +101,9 @@ func ShouldSendEvent(webCon *WebConn, msg *model.Message) bool {
return false
} else if msg.Action == model.ACTION_PREFERENCE_CHANGED {
return false
+ } else if msg.Action == model.ACTION_EPHEMERAL_MESSAGE {
+ // For now, ephemeral messages are sent directly to individual users
+ return false
}
// Only report events to a user who is the subject of the event, or is in the channel of the event
diff --git a/i18n/en.json b/i18n/en.json
index 74a873204..88d857fce 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -612,6 +612,10 @@
"translation": "License did not save properly."
},
{
+ "id": "api.license.add_license.save_active.app_error",
+ "translation": "Active license ID did not save properly."
+ },
+ {
"id": "api.license.add_license.unique_users.app_error",
"translation": "This license only supports {{.Users}} users, when your system has {{.Count}} unique users. Unique users are counted distinctly by email address. You can see total user count under Site Reports -> View Statistics."
},
@@ -676,6 +680,14 @@
"translation": "Error getting access token from DB before deletion"
},
{
+ "id": "api.post.check_for_out_of_channel_mentions.message.one",
+ "translation": "{{.Username}} was mentioned, but they do not belong to this channel."
+ },
+ {
+ "id": "api.post.check_for_out_of_channel_mentions.message.multiple",
+ "translation": "{{.Usernames}} and {{.LastUsername}} were mentioned, but they do not belong to this channel."
+ },
+ {
"id": "api.post.create_post.bad_filename.error",
"translation": "Bad filename discarded, filename=%v"
},
@@ -704,6 +716,10 @@
"translation": "You do not have the appropriate permissions"
},
{
+ "id": "api.post.get_out_of_channel_mentions.regex.error",
+ "translation": "Failed to compile @mention regex user_id=%v, err=%v"
+ },
+ {
"id": "api.post.get_post.permissions.app_error",
"translation": "You do not have the appropriate permissions"
},
@@ -712,6 +728,14 @@
"translation": "Encountered error getting channel, channel_id=%s, err=%v"
},
{
+ "id": "api.post.handle_post_events_and_forget.members.error",
+ "translation": "Failed to get channel members channel_id=%v err=%v"
+ },
+ {
+ "id": "api.post.handle_post_events_and_forget.profiles.error",
+ "translation": "Failed to retrieve user profiles team_id=%v, err=%v"
+ },
+ {
"id": "api.post.handle_post_events_and_forget.team.error",
"translation": "Encountered error getting team, team_id=%s, err=%v"
},
@@ -752,10 +776,6 @@
"translation": "Failed to update direct channel preference user_id=%v other_user_id=%v err=%v"
},
{
- "id": "api.post.send_notifications_and_forget.members.error",
- "translation": "Failed to get channel members channel_id=%v err=%v"
- },
- {
"id": "api.post.send_notifications_and_forget.mention_body",
"translation": "You have one new mention."
},
@@ -788,10 +808,6 @@
"translation": "Failed to send push notificationid=%v, err=%v"
},
{
- "id": "api.post.send_notifications_and_forget.retrive_profiles.error",
- "translation": "Failed to retrieve user profiles team_id=%v, err=%v"
- },
- {
"id": "api.post.send_notifications_and_forget.send.error",
"translation": "Failed to send mention email successfully email=%v err=%v"
},
@@ -1732,6 +1748,10 @@
"translation": "Unable to get channels"
},
{
+ "id": "mattermost.load_license.find.warn",
+ "translation": "Unable to find active license"
+ },
+ {
"id": "mattermost.bulletin.subject",
"translation": "Mattermost Security Bulletin"
},
@@ -2916,6 +2936,18 @@
"translation": "We encountered an error updating the system property"
},
{
+ "id": "store.sql_license.save.app_error",
+ "translation": "We encountered an error saving the license"
+ },
+ {
+ "id": "store.sql_license.get.app_error",
+ "translation": "We encountered an error getting the license"
+ },
+ {
+ "id": "store.sql_license.get.missing.app_error",
+ "translation": "A license with that ID was not found"
+ },
+ {
"id": "store.sql_team.get.find.app_error",
"translation": "We couldn't find the existing team"
},
@@ -3204,10 +3236,6 @@
"translation": "No valid enterprise license found"
},
{
- "id": "utils.license.load_license.open_find.warn",
- "translation": "Unable to open/find license file"
- },
- {
"id": "utils.license.remove_license.unable.error",
"translation": "Unable to remove license file, err=%v"
},
@@ -3531,4 +3559,4 @@
"id": "web.watcher_fail.error",
"translation": "Failed to add directory to watcher %v"
}
-] \ No newline at end of file
+]
diff --git a/i18n/es.json b/i18n/es.json
index 2fe02b2b0..6bc1fd602 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -712,6 +712,14 @@
"translation": "Se encontró un error obteniendo el canal, channel_id=%s, err=%v"
},
{
+ "id": "api.post.handle_post_events_and_forget.members.error",
+ "translation": "Falla al obtener los miembros del canal channel_id=%v err=%v"
+ },
+ {
+ "id": "api.post.handle_post_events_and_forget.profiles.error",
+ "translation": "Falla al recuperar los perfiles de usuario team_id=%v, err=%v"
+ },
+ {
"id": "api.post.handle_post_events_and_forget.team.error",
"translation": "Se encontró un error obteniendo el equipo, team_id=%s, err=%v"
},
@@ -752,10 +760,6 @@
"translation": "Falla al actualizar las preferencias del canal directo user_id=%v other_user_id=%v err=%v"
},
{
- "id": "api.post.send_notifications_and_forget.members.error",
- "translation": "Falla al obtener los miembros del canal channel_id=%v err=%v"
- },
- {
"id": "api.post.send_notifications_and_forget.mention_body",
"translation": "Tienes una mención nueva."
},
@@ -788,10 +792,6 @@
"translation": "Falló el envio de la notificación push notificationid=%v, err=%v"
},
{
- "id": "api.post.send_notifications_and_forget.retrive_profiles.error",
- "translation": "Falla al recuperar los perfiles de usuario team_id=%v, err=%v"
- },
- {
"id": "api.post.send_notifications_and_forget.send.error",
"translation": "Falla al enviar el correo con la mención satisfactoriamente email=%v err=%v"
},
@@ -3204,10 +3204,6 @@
"translation": "No se encontró una licencia enterprise válida"
},
{
- "id": "utils.license.load_license.open_find.warn",
- "translation": "No pudimos encontrar/abrir el achivo de licencia"
- },
- {
"id": "utils.license.remove_license.unable.error",
"translation": "No se pudo remover el archivo de la licencia, err=%v"
},
@@ -3531,4 +3527,4 @@
"id": "web.watcher_fail.error",
"translation": "Falla al agregar el directorio a ser vigilado %v"
}
-] \ No newline at end of file
+]
diff --git a/mattermost.go b/mattermost.go
index 43fa06601..5a18e2f40 100644
--- a/mattermost.go
+++ b/mattermost.go
@@ -69,7 +69,7 @@ func main() {
web.InitWeb()
if model.BuildEnterpriseReady == "true" {
- utils.LoadLicense()
+ loadLicense()
}
if flagRunCmds {
@@ -95,6 +95,26 @@ func main() {
}
}
+func loadLicense() {
+ licenseId := ""
+ if result := <-api.Srv.Store.System().Get(); result.Err == nil {
+ props := result.Data.(model.StringMap)
+ licenseId = props[model.SYSTEM_ACTIVE_LICENSE_ID]
+ }
+
+ if len(licenseId) != 26 {
+ l4g.Warn(utils.T("mattermost.load_license.find.warn"))
+ return
+ }
+
+ if result := <-api.Srv.Store.License().Get(licenseId); result.Err == nil {
+ record := result.Data.(*model.LicenseRecord)
+ utils.LoadLicense([]byte(record.Bytes))
+ } else {
+ l4g.Warn(utils.T("mattermost.load_license.find.warn"))
+ }
+}
+
func setDiagnosticId() {
if result := <-api.Srv.Store.System().Get(); result.Err == nil {
props := result.Data.(model.StringMap)
diff --git a/model/license.go b/model/license.go
index a271b46b7..ea66fef0d 100644
--- a/model/license.go
+++ b/model/license.go
@@ -8,6 +8,12 @@ import (
"io"
)
+type LicenseRecord struct {
+ Id string `json:"id"`
+ CreateAt int64 `json:"create_at"`
+ Bytes string `json:"-"`
+}
+
type License struct {
Id string `json:"id"`
IssuedAt int64 `json:"issued_at"`
@@ -83,3 +89,23 @@ func LicenseFromJson(data io.Reader) *License {
return nil
}
}
+
+func (lr *LicenseRecord) IsValid() *AppError {
+ if len(lr.Id) != 26 {
+ return NewLocAppError("LicenseRecord.IsValid", "model.license_record.is_valid.id.app_error", nil, "")
+ }
+
+ if lr.CreateAt == 0 {
+ return NewLocAppError("LicenseRecord.IsValid", "model.license_record.is_valid.create_at.app_error", nil, "")
+ }
+
+ if len(lr.Bytes) == 0 || len(lr.Bytes) > 10000 {
+ return NewLocAppError("LicenseRecord.IsValid", "model.license_record.is_valid.create_at.app_error", nil, "")
+ }
+
+ return nil
+}
+
+func (lr *LicenseRecord) PreSave() {
+ lr.CreateAt = GetMillis()
+}
diff --git a/model/message.go b/model/message.go
index 1cb350bbf..cce0ec094 100644
--- a/model/message.go
+++ b/model/message.go
@@ -18,6 +18,7 @@ const (
ACTION_USER_ADDED = "user_added"
ACTION_USER_REMOVED = "user_removed"
ACTION_PREFERENCE_CHANGED = "preference_changed"
+ ACTION_EPHEMERAL_MESSAGE = "ephemeral_message"
)
type Message struct {
diff --git a/model/post.go b/model/post.go
index f9f5a4d1c..8a451831c 100644
--- a/model/post.go
+++ b/model/post.go
@@ -13,8 +13,10 @@ const (
POST_SYSTEM_MESSAGE_PREFIX = "system_"
POST_DEFAULT = ""
POST_SLACK_ATTACHMENT = "slack_attachment"
+ POST_SYSTEM_GENERIC = "system_generic"
POST_JOIN_LEAVE = "system_join_leave"
POST_HEADER_CHANGE = "system_header_change"
+ POST_EPHEMERAL = "system_ephemeral"
)
type Post struct {
diff --git a/model/system.go b/model/system.go
index 70db529d5..b387749f6 100644
--- a/model/system.go
+++ b/model/system.go
@@ -12,6 +12,7 @@ const (
SYSTEM_DIAGNOSTIC_ID = "DiagnosticId"
SYSTEM_RAN_UNIT_TESTS = "RanUnitTests"
SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime"
+ SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId"
)
type System struct {
diff --git a/store/sql_license_store.go b/store/sql_license_store.go
new file mode 100644
index 000000000..f5d67bc5d
--- /dev/null
+++ b/store/sql_license_store.go
@@ -0,0 +1,83 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package store
+
+import (
+ "github.com/mattermost/platform/model"
+)
+
+type SqlLicenseStore struct {
+ *SqlStore
+}
+
+func NewSqlLicenseStore(sqlStore *SqlStore) LicenseStore {
+ ls := &SqlLicenseStore{sqlStore}
+
+ for _, db := range sqlStore.GetAllConns() {
+ table := db.AddTableWithName(model.LicenseRecord{}, "Licenses").SetKeys(false, "Id")
+ table.ColMap("Id").SetMaxSize(26)
+ table.ColMap("Bytes").SetMaxSize(10000)
+ }
+
+ return ls
+}
+
+func (ls SqlLicenseStore) UpgradeSchemaIfNeeded() {
+}
+
+func (ls SqlLicenseStore) CreateIndexesIfNotExists() {
+}
+
+func (ls SqlLicenseStore) Save(license *model.LicenseRecord) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ license.PreSave()
+ if result.Err = license.IsValid(); result.Err != nil {
+ storeChannel <- result
+ close(storeChannel)
+ return
+ }
+
+ // Only insert if not exists
+ if err := ls.GetReplica().SelectOne(&model.LicenseRecord{}, "SELECT * FROM Licenses WHERE Id = :Id", map[string]interface{}{"Id": license.Id}); err != nil {
+ if err := ls.GetMaster().Insert(license); err != nil {
+ result.Err = model.NewLocAppError("SqlLicenseStore.Save", "store.sql_license.save.app_error", nil, "license_id="+license.Id+", "+err.Error())
+ } else {
+ result.Data = license
+ }
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (ls SqlLicenseStore) Get(id string) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ if obj, err := ls.GetReplica().Get(model.LicenseRecord{}, id); err != nil {
+ result.Err = model.NewLocAppError("SqlLicenseStore.Get", "store.sql_license.get.app_error", nil, "license_id="+id+", "+err.Error())
+ } else if obj == nil {
+ result.Err = model.NewLocAppError("SqlLicenseStore.Get", "store.sql_license.get.missing.app_error", nil, "license_id="+id)
+ } else {
+ result.Data = obj.(*model.LicenseRecord)
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_license_store_test.go b/store/sql_license_store_test.go
new file mode 100644
index 000000000..ad24a6af7
--- /dev/null
+++ b/store/sql_license_store_test.go
@@ -0,0 +1,43 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package store
+
+import (
+ "github.com/mattermost/platform/model"
+ "testing"
+)
+
+func TestLicenseStoreSave(t *testing.T) {
+ Setup()
+
+ l1 := model.LicenseRecord{}
+ l1.Id = model.NewId()
+ l1.Bytes = "junk"
+
+ if err := (<-store.License().Save(&l1)).Err; err != nil {
+ t.Fatal("couldn't save license record", err)
+ }
+
+ if err := (<-store.License().Save(&l1)).Err; err != nil {
+ t.Fatal("shouldn't fail on trying to save existing license record", err)
+ }
+}
+
+func TestLicenseStoreGet(t *testing.T) {
+ Setup()
+
+ l1 := model.LicenseRecord{}
+ l1.Id = model.NewId()
+ l1.Bytes = "junk"
+
+ Must(store.License().Save(&l1))
+
+ if r := <-store.License().Get(l1.Id); r.Err != nil {
+ t.Fatal("couldn't get license", r.Err)
+ } else {
+ if r.Data.(*model.LicenseRecord).Bytes != l1.Bytes {
+ t.Fatal("license bytes didn't match")
+ }
+ }
+}
diff --git a/store/sql_store.go b/store/sql_store.go
index 8517eb1a2..a994ec57e 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -49,6 +49,7 @@ type SqlStore struct {
webhook WebhookStore
command CommandStore
preference PreferenceStore
+ license LicenseStore
}
func NewSqlStore() Store {
@@ -103,6 +104,7 @@ func NewSqlStore() Store {
sqlStore.webhook = NewSqlWebhookStore(sqlStore)
sqlStore.command = NewSqlCommandStore(sqlStore)
sqlStore.preference = NewSqlPreferenceStore(sqlStore)
+ sqlStore.license = NewSqlLicenseStore(sqlStore)
err := sqlStore.master.CreateTablesIfNotExists()
if err != nil {
@@ -120,6 +122,7 @@ func NewSqlStore() Store {
sqlStore.webhook.(*SqlWebhookStore).UpgradeSchemaIfNeeded()
sqlStore.command.(*SqlCommandStore).UpgradeSchemaIfNeeded()
sqlStore.preference.(*SqlPreferenceStore).UpgradeSchemaIfNeeded()
+ sqlStore.license.(*SqlLicenseStore).UpgradeSchemaIfNeeded()
sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists()
sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists()
@@ -132,6 +135,7 @@ func NewSqlStore() Store {
sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists()
sqlStore.command.(*SqlCommandStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists()
+ sqlStore.license.(*SqlLicenseStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures()
@@ -523,6 +527,10 @@ func (ss SqlStore) Preference() PreferenceStore {
return ss.preference
}
+func (ss SqlStore) License() LicenseStore {
+ return ss.license
+}
+
type mattermConverter struct{}
func (me mattermConverter) ToDb(val interface{}) (interface{}, error) {
diff --git a/store/sql_system_store.go b/store/sql_system_store.go
index cfd4a670f..f8da06cec 100644
--- a/store/sql_system_store.go
+++ b/store/sql_system_store.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package store
@@ -47,6 +47,30 @@ func (s SqlSystemStore) Save(system *model.System) StoreChannel {
return storeChannel
}
+func (s SqlSystemStore) SaveOrUpdate(system *model.System) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ if err := s.GetReplica().SelectOne(&model.System{}, "SELECT * FROM Systems WHERE Name = :Name", map[string]interface{}{"Name": system.Name}); err == nil {
+ if _, err := s.GetMaster().Update(system); err != nil {
+ result.Err = model.NewLocAppError("SqlSystemStore.SaveOrUpdate", "store.sql_system.update.app_error", nil, "")
+ }
+ } else {
+ if err := s.GetMaster().Insert(system); err != nil {
+ result.Err = model.NewLocAppError("SqlSystemStore.SaveOrUpdate", "store.sql_system.save.app_error", nil, "")
+ }
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (s SqlSystemStore) Update(system *model.System) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_system_store_test.go b/store/sql_system_store_test.go
index 8ff5445cc..ce149e97a 100644
--- a/store/sql_system_store_test.go
+++ b/store/sql_system_store_test.go
@@ -31,3 +31,19 @@ func TestSqlSystemStore(t *testing.T) {
t.Fatal()
}
}
+
+func TestSqlSystemStoreSaveOrUpdate(t *testing.T) {
+ Setup()
+
+ system := &model.System{Name: model.NewId(), Value: "value"}
+
+ if err := (<-store.System().SaveOrUpdate(system)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ system.Value = "value2"
+
+ if r := <-store.System().SaveOrUpdate(system); r.Err != nil {
+ t.Fatal(r.Err)
+ }
+}
diff --git a/store/store.go b/store/store.go
index b6b86e0d9..952b96e87 100644
--- a/store/store.go
+++ b/store/store.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package store
@@ -39,6 +39,7 @@ type Store interface {
Webhook() WebhookStore
Command() CommandStore
Preference() PreferenceStore
+ License() LicenseStore
MarkSystemRanUnitTests()
Close()
}
@@ -164,6 +165,7 @@ type OAuthStore interface {
type SystemStore interface {
Save(system *model.System) StoreChannel
+ SaveOrUpdate(system *model.System) StoreChannel
Update(system *model.System) StoreChannel
Get() StoreChannel
}
@@ -203,3 +205,8 @@ type PreferenceStore interface {
PermanentDeleteByUser(userId string) StoreChannel
IsFeatureEnabled(feature, userId string) StoreChannel
}
+
+type LicenseStore interface {
+ Save(license *model.LicenseRecord) StoreChannel
+ Get(id string) StoreChannel
+}
diff --git a/utils/license.go b/utils/license.go
index 0d1cd597c..b773a163e 100644
--- a/utils/license.go
+++ b/utils/license.go
@@ -1,19 +1,15 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package utils
import (
- "bytes"
"crypto"
"crypto/rsa"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/pem"
- "io"
- "os"
- "path/filepath"
"strconv"
"strings"
@@ -22,10 +18,6 @@ import (
"github.com/mattermost/platform/model"
)
-const (
- LICENSE_FILENAME = "active.dat"
-)
-
var IsLicensed bool = false
var License *model.License = &model.License{}
var ClientLicense map[string]string = make(map[string]string)
@@ -41,18 +33,8 @@ NxpC+5KFhU+xSeeklNqwCgnlOyZ7qSTxmdJHb+60SwuYnnGIYzLJhY4LYDr4J+KR
1wIDAQAB
-----END PUBLIC KEY-----`)
-func LoadLicense() {
- file, err := os.Open(LicenseLocation())
- if err != nil {
- l4g.Warn(T("utils.license.load_license.open_find.warn"))
- return
- }
- defer file.Close()
-
- buf := bytes.NewBuffer(nil)
- io.Copy(buf, file)
-
- if success, licenseStr := ValidateLicense(buf.Bytes()); success {
+func LoadLicense(licenseBytes []byte) {
+ if success, licenseStr := ValidateLicense(licenseBytes); success {
license := model.LicenseFromJson(strings.NewReader(licenseStr))
SetLicense(license)
return
@@ -74,21 +56,10 @@ func SetLicense(license *model.License) bool {
return false
}
-func LicenseLocation() string {
- return filepath.Dir(CfgFileName) + "/" + LICENSE_FILENAME
-}
-
-func RemoveLicense() bool {
+func RemoveLicense() {
License = &model.License{}
IsLicensed = false
ClientLicense = getClientLicense(License)
-
- if err := os.Remove(LicenseLocation()); err != nil {
- l4g.Error(T("utils.license.remove_license.unable.error"), err.Error())
- return false
- }
-
- return true
}
func ValidateLicense(signed []byte) (bool, string) {
diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx
index 98b1d7cc1..af4d3fb0f 100644
--- a/web/react/components/access_history_modal.jsx
+++ b/web/react/components/access_history_modal.jsx
@@ -73,7 +73,8 @@ class AccessHistoryModal extends React.Component {
content = (
<AuditTable
audits={this.state.audits}
- moreInfo={this.state.moreInfo}
+ showIp={true}
+ showSession={true}
/>
);
}
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx
index 360ae3ef3..695e2083a 100644
--- a/web/react/components/admin_console/admin_controller.jsx
+++ b/web/react/components/admin_console/admin_controller.jsx
@@ -160,7 +160,7 @@ export default class AdminController extends React.Component {
} else if (this.state.selected === 'ldap_settings') {
tab = <LdapSettingsTab config={this.state.config} />;
} else if (this.state.selected === 'license') {
- tab = <LicenseSettingsTab />;
+ tab = <LicenseSettingsTab config={this.state.config} />;
} else if (this.state.selected === 'team_users') {
if (this.state.teams) {
tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />;
diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx
index 642bfe9d7..eadd8d412 100644
--- a/web/react/components/admin_console/admin_sidebar.jsx
+++ b/web/react/components/admin_console/admin_sidebar.jsx
@@ -225,7 +225,7 @@ export default class AdminSidebar extends React.Component {
>
<FormattedMessage
id='admin.sidebar.audits'
- defaultMessage='Audits'
+ defaultMessage='Compliance and Auditing'
/>
</a>
</li>
@@ -454,6 +454,7 @@ export default class AdminSidebar extends React.Component {
</ul>
<ul className='nav nav__sub-menu padded'>
{licenseSettings}
+ {audits}
<li>
<a
href='#'
@@ -466,7 +467,6 @@ export default class AdminSidebar extends React.Component {
/>
</a>
</li>
- {audits}
</ul>
</li>
</ul>
diff --git a/web/react/components/admin_console/audits.jsx b/web/react/components/admin_console/audits.jsx
index 866539b3d..173e63b45 100644
--- a/web/react/components/admin_console/audits.jsx
+++ b/web/react/components/admin_console/audits.jsx
@@ -60,8 +60,9 @@ export default class Audits extends React.Component {
<div style={{margin: '10px'}}>
<AuditTable
audits={this.state.audits}
- oneLine={true}
showUserId={true}
+ showIp={true}
+ showSession={true}
/>
</div>
);
@@ -72,7 +73,7 @@ export default class Audits extends React.Component {
<h3>
<FormattedMessage
id='admin.audits.title'
- defaultMessage='Server Audits'
+ defaultMessage='User Activity'
/>
</h3>
<button
diff --git a/web/react/components/admin_console/license_settings.jsx b/web/react/components/admin_console/license_settings.jsx
index 539acd869..3332f37ef 100644
--- a/web/react/components/admin_console/license_settings.jsx
+++ b/web/react/components/admin_console/license_settings.jsx
@@ -109,7 +109,17 @@ class LicenseSettings extends React.Component {
);
licenseType = (
<FormattedHTMLMessage
- id='admin.license.entrepriseType'
+ id='admin.license.enterpriseType'
+ values={{
+ terms: global.window.mm_config.TermsOfServiceLink,
+ name: global.window.mm_license.Name,
+ company: global.window.mm_license.Company,
+ users: global.window.mm_license.Users,
+ issued: Utils.displayDate(parseInt(global.window.mm_license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(global.window.mm_license.IssuedAt, 10), true),
+ start: Utils.displayDate(parseInt(global.window.mm_license.StartsAt, 10)),
+ expires: Utils.displayDate(parseInt(global.window.mm_license.ExpiresAt, 10)),
+ ldap: global.window.mm_license.LDAP
+ }}
defaultMessage='<div><p>This compiled release of Mattermost platform is provided under a <a href="http://mattermost.com" target="_blank">commercial license</a>
from Mattermost, Inc. based on your subscription level and is subject to the <a href="{terms}" target="_blank">Terms of Service.</a></p>
<p>Your subscription details are as follows:</p>
@@ -126,6 +136,7 @@ class LicenseSettings extends React.Component {
licenseKey = (
<div className='col-sm-8'>
<button
+ disabled={this.props.config.LdapSettings.Enable}
className='btn btn-danger'
onClick={this.handleRemove}
id='remove-button'
@@ -256,7 +267,8 @@ class LicenseSettings extends React.Component {
}
LicenseSettings.propTypes = {
- intl: intlShape.isRequired
+ intl: intlShape.isRequired,
+ config: React.PropTypes.object
};
-export default injectIntl(LicenseSettings); \ No newline at end of file
+export default injectIntl(LicenseSettings);
diff --git a/web/react/components/audit_table.jsx b/web/react/components/audit_table.jsx
index cdca7e8d6..49892ff98 100644
--- a/web/react/components/audit_table.jsx
+++ b/web/react/components/audit_table.jsx
@@ -183,389 +183,388 @@ const holders = defineMessages({
loginFailure: {
id: 'audit_table.loginFailure',
defaultMessage: ' (Login failure)'
- },
- userId: {
- id: 'audit_table.userId',
- defaultMessage: 'User ID'
}
});
class AuditTable extends React.Component {
constructor(props) {
super(props);
-
- this.handleMoreInfo = this.handleMoreInfo.bind(this);
- this.formatAuditInfo = this.formatAuditInfo.bind(this);
- this.handleRevokedSession = this.handleRevokedSession.bind(this);
-
- this.state = {moreInfo: []};
- }
- handleMoreInfo(index) {
- var newMoreInfo = this.state.moreInfo;
- newMoreInfo[index] = true;
- this.setState({moreInfo: newMoreInfo});
}
- handleRevokedSession(sessionId) {
- return this.props.intl.formatMessage(holders.sessionRevoked, {sessionId: sessionId});
- }
- formatAuditInfo(currentAudit) {
- const currentActionURL = currentAudit.action.replace(/\/api\/v[1-9]/, '');
+ render() {
+ var accessList = [];
const {formatMessage} = this.props.intl;
- let currentAuditDesc = '';
-
- if (currentActionURL.indexOf('/channels') === 0) {
- const channelInfo = currentAudit.extra_info.split(' ');
- const channelNameField = channelInfo[0].split('=');
-
- let channelURL = '';
- let channelObj;
- let channelName = '';
- if (channelNameField.indexOf('name') >= 0) {
- channelURL = channelNameField[channelNameField.indexOf('name') + 1];
- channelObj = ChannelStore.getByName(channelURL);
- if (channelObj) {
- channelName = channelObj.display_name;
- } else {
- channelName = channelURL;
- }
- }
+ for (var i = 0; i < this.props.audits.length; i++) {
+ const audit = this.props.audits[i];
+ const auditInfo = formatAuditInfo(audit, formatMessage);
- switch (currentActionURL) {
- case '/channels/create':
- currentAuditDesc = formatMessage(holders.channelCreated, {channelName: channelName});
- break;
- case '/channels/create_direct':
- currentAuditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username});
- break;
- case '/channels/update':
- currentAuditDesc = formatMessage(holders.nameUpdated, {channelName: channelName});
- break;
- case '/channels/update_desc': // support the old path
- case '/channels/update_header':
- currentAuditDesc = formatMessage(holders.headerUpdated, {channelName: channelName});
- break;
- default: {
- let userIdField = [];
- let userId = '';
- let username = '';
-
- if (channelInfo[1]) {
- userIdField = channelInfo[1].split('=');
-
- if (userIdField.indexOf('user_id') >= 0) {
- userId = userIdField[userIdField.indexOf('user_id') + 1];
- username = UserStore.getProfile(userId).username;
- }
- }
+ let uContent;
+ if (this.props.showUserId) {
+ uContent = <td>{auditInfo.userId}</td>;
+ }
- if (/\/channels\/[A-Za-z0-9]+\/delete/.test(currentActionURL)) {
- currentAuditDesc = formatMessage(holders.channelDeleted, {url: channelURL});
- } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(currentActionURL)) {
- currentAuditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName});
- } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(currentActionURL)) {
- currentAuditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName});
- }
+ let iContent;
+ if (this.props.showIp) {
+ iContent = <td>{auditInfo.ip}</td>;
+ }
- break;
+ let sContent;
+ if (this.props.showSession) {
+ sContent = <td>{auditInfo.sessionId}</td>;
}
+
+ let descStyle = {};
+ if (auditInfo.desc.toLowerCase().indexOf('fail') !== -1) {
+ descStyle.color = 'red';
}
- } else if (currentActionURL.indexOf('/oauth') === 0) {
- const oauthInfo = currentAudit.extra_info.split(' ');
- switch (currentActionURL) {
- case '/oauth/register': {
- const clientIdField = oauthInfo[0].split('=');
+ accessList[i] = (
+ <tr key={audit.id}>
+ <td>{auditInfo.timestamp}</td>
+ {uContent}
+ <td style={descStyle}>{auditInfo.desc}</td>
+ {iContent}
+ {sContent}
+ </tr>
+ );
+ }
- if (clientIdField[0] === 'client_id') {
- currentAuditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]});
- }
+ let userIdContent;
+ if (this.props.showUserId) {
+ userIdContent = (
+ <th>
+ <FormattedMessage
+ id='audit_table.userId'
+ defaultMessage='User ID'
+ />
+ </th>
+ );
+ }
- break;
- }
- case '/oauth/allow':
- if (oauthInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedAllowOAuthAccess);
- } else if (oauthInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullOAuthAccess);
- } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') {
- currentAuditDesc = formatMessage(holders.failedOAuthAccess);
- }
+ let ipContent;
+ if (this.props.showIp) {
+ ipContent = (
+ <th>
+ <FormattedMessage
+ id='audit_table.ip'
+ defaultMessage='IP Address'
+ />
+ </th>
+ );
+ }
- break;
- case '/oauth/access_token':
- if (oauthInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedOAuthToken);
- } else if (oauthInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullOAuthToken);
- } else {
- const oauthTokenFailure = oauthInfo[0].split('-');
-
- if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) {
- currentAuditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()});
- }
- }
+ let sessionContent;
+ if (this.props.showSession) {
+ sessionContent = (
+ <th>
+ <FormattedMessage
+ id='audit_table.session'
+ defaultMessage='Session ID'
+ />
+ </th>
+ );
+ }
- break;
- default:
- break;
- }
- } else if (currentActionURL.indexOf('/users') === 0) {
- const userInfo = currentAudit.extra_info.split(' ');
-
- switch (currentActionURL) {
- case '/users/login':
- if (userInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedLogin);
- } else if (userInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullLogin);
- } else if (userInfo[0]) {
- currentAuditDesc = formatMessage(holders.failedLogin);
- }
+ return (
+ <table className='table'>
+ <thead>
+ <tr>
+ <th>
+ <FormattedMessage
+ id='audit_table.timestamp'
+ defaultMessage='Timestamp'
+ />
+ </th>
+ {userIdContent}
+ <th>
+ <FormattedMessage
+ id='audit_table.action'
+ defaultMessage='Action'
+ />
+ </th>
+ {ipContent}
+ {sessionContent}
+ </tr>
+ </thead>
+ <tbody>
+ {accessList}
+ </tbody>
+ </table>
+ );
+ }
+}
- break;
- case '/users/revoke_session':
- currentAuditDesc = this.handleRevokedSession(userInfo[0].split('=')[1]);
- break;
- case '/users/newimage':
- currentAuditDesc = formatMessage(holders.updatePicture);
- break;
- case '/users/update':
- currentAuditDesc = formatMessage(holders.updateGeneral);
- break;
- case '/users/newpassword':
- if (userInfo[0] === 'attempted') {
- currentAuditDesc = formatMessage(holders.attemptedPassword);
- } else if (userInfo[0] === 'completed') {
- currentAuditDesc = formatMessage(holders.successfullPassword);
- } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') {
- currentAuditDesc = formatMessage(holders.failedPassword);
- }
+AuditTable.propTypes = {
+ intl: intlShape.isRequired,
+ audits: React.PropTypes.array.isRequired,
+ showUserId: React.PropTypes.bool,
+ showIp: React.PropTypes.bool,
+ showSession: React.PropTypes.bool
+};
- break;
- case '/users/update_roles': {
- const userRoles = userInfo[0].split('=')[1];
+export default injectIntl(AuditTable);
- currentAuditDesc = formatMessage(holders.updatedRol);
- if (userRoles.trim()) {
- currentAuditDesc += userRoles;
- } else {
- currentAuditDesc += formatMessage(holders.member);
+export function formatAuditInfo(audit, formatMessage) {
+ const actionURL = audit.action.replace(/\/api\/v[1-9]/, '');
+ let auditDesc = '';
+
+ if (actionURL.indexOf('/channels') === 0) {
+ const channelInfo = audit.extra_info.split(' ');
+ const channelNameField = channelInfo[0].split('=');
+
+ let channelURL = '';
+ let channelObj;
+ let channelName = '';
+ if (channelNameField.indexOf('name') >= 0) {
+ channelURL = channelNameField[channelNameField.indexOf('name') + 1];
+ channelObj = ChannelStore.getByName(channelURL);
+ if (channelObj) {
+ channelName = channelObj.display_name;
+ } else {
+ channelName = channelURL;
+ }
+ }
+
+ switch (actionURL) {
+ case '/channels/create':
+ auditDesc = formatMessage(holders.channelCreated, {channelName: channelName});
+ break;
+ case '/channels/create_direct':
+ auditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username});
+ break;
+ case '/channels/update':
+ auditDesc = formatMessage(holders.nameUpdated, {channelName: channelName});
+ break;
+ case '/channels/update_desc': // support the old path
+ case '/channels/update_header':
+ auditDesc = formatMessage(holders.headerUpdated, {channelName: channelName});
+ break;
+ default: {
+ let userIdField = [];
+ let userId = '';
+ let username = '';
+
+ if (channelInfo[1]) {
+ userIdField = channelInfo[1].split('=');
+
+ if (userIdField.indexOf('user_id') >= 0) {
+ userId = userIdField[userIdField.indexOf('user_id') + 1];
+ username = UserStore.getProfile(userId).username;
}
+ }
- break;
+ if (/\/channels\/[A-Za-z0-9]+\/delete/.test(actionURL)) {
+ auditDesc = formatMessage(holders.channelDeleted, {url: channelURL});
+ } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(actionURL)) {
+ auditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName});
+ } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(actionURL)) {
+ auditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName});
}
- case '/users/update_active': {
- const updateType = userInfo[0].split('=')[0];
- const updateField = userInfo[0].split('=')[1];
-
- /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */
- if (updateType === 'active') {
- if (updateField === 'true') {
- currentAuditDesc = formatMessage(holders.accountActive);
- } else if (updateField === 'false') {
- currentAuditDesc = formatMessage(holders.accountInactive);
- }
- const actingUserInfo = userInfo[1].split('=');
- if (actingUserInfo[0] === 'session_user') {
- const actingUser = UserStore.getProfile(actingUserInfo[1]);
- const currentUser = UserStore.getCurrentUser();
- if (currentUser && actingUser && (Utils.isAdmin(currentUser.roles) || Utils.isSystemAdmin(currentUser.roles))) {
- currentAuditDesc += formatMessage(holders.by, {username: actingUser.username});
- } else if (currentUser && actingUser) {
- currentAuditDesc += formatMessage(holders.byAdmin);
- }
- }
- } else if (updateType === 'session_id') {
- currentAuditDesc = this.handleRevokedSession(updateField);
- }
+ break;
+ }
+ }
+ } else if (actionURL.indexOf('/oauth') === 0) {
+ const oauthInfo = audit.extra_info.split(' ');
+
+ switch (actionURL) {
+ case '/oauth/register': {
+ const clientIdField = oauthInfo[0].split('=');
- break;
+ if (clientIdField[0] === 'client_id') {
+ auditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]});
}
- case '/users/send_password_reset':
- currentAuditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]});
- break;
- case '/users/reset_password':
- if (userInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedReset);
- } else if (userInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullReset);
- }
- break;
- case '/users/update_notify':
- currentAuditDesc = formatMessage(holders.updateGlobalNotifications);
- break;
- default:
- break;
+ break;
+ }
+ case '/oauth/allow':
+ if (oauthInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedAllowOAuthAccess);
+ } else if (oauthInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullOAuthAccess);
+ } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') {
+ auditDesc = formatMessage(holders.failedOAuthAccess);
}
- } else if (currentActionURL.indexOf('/hooks') === 0) {
- const webhookInfo = currentAudit.extra_info.split(' ');
-
- switch (currentActionURL) {
- case '/hooks/incoming/create':
- if (webhookInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedWebhookCreate);
- } else if (webhookInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.succcessfullWebhookCreate);
- } else if (webhookInfo[0] === 'fail - bad channel permissions') {
- currentAuditDesc = formatMessage(holders.failedWebhookCreate);
- }
- break;
- case '/hooks/incoming/delete':
- if (webhookInfo[0] === 'attempt') {
- currentAuditDesc = formatMessage(holders.attemptedWebhookDelete);
- } else if (webhookInfo[0] === 'success') {
- currentAuditDesc = formatMessage(holders.successfullWebhookDelete);
- } else if (webhookInfo[0] === 'fail - inappropriate conditions') {
- currentAuditDesc = formatMessage(holders.failedWebhookDelete);
+ break;
+ case '/oauth/access_token':
+ if (oauthInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedOAuthToken);
+ } else if (oauthInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullOAuthToken);
+ } else {
+ const oauthTokenFailure = oauthInfo[0].split('-');
+
+ if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) {
+ auditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()});
}
+ }
- break;
- default:
- break;
+ break;
+ default:
+ break;
+ }
+ } else if (actionURL.indexOf('/users') === 0) {
+ const userInfo = audit.extra_info.split(' ');
+
+ switch (actionURL) {
+ case '/users/login':
+ if (userInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedLogin);
+ } else if (userInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullLogin);
+ } else if (userInfo[0]) {
+ auditDesc = formatMessage(holders.failedLogin);
}
- } else {
- switch (currentActionURL) {
- case '/logout':
- currentAuditDesc = formatMessage(holders.logout);
- break;
- case '/verify_email':
- currentAuditDesc = formatMessage(holders.verified);
- break;
- default:
- break;
+
+ break;
+ case '/users/revoke_session':
+ auditDesc = formatMessage(holders.sessionRevoked, {sessionId: userInfo[0].split('=')[1]});
+ break;
+ case '/users/newimage':
+ auditDesc = formatMessage(holders.updatePicture);
+ break;
+ case '/users/update':
+ auditDesc = formatMessage(holders.updateGeneral);
+ break;
+ case '/users/newpassword':
+ if (userInfo[0] === 'attempted') {
+ auditDesc = formatMessage(holders.attemptedPassword);
+ } else if (userInfo[0] === 'completed') {
+ auditDesc = formatMessage(holders.successfullPassword);
+ } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') {
+ auditDesc = formatMessage(holders.failedPassword);
}
- }
- /* If all else fails... */
- if (!currentAuditDesc) {
- /* Currently not called anywhere */
- if (currentAudit.extra_info.indexOf('revoked_all=') >= 0) {
- currentAuditDesc = formatMessage(holders.revokedAll);
+ break;
+ case '/users/update_roles': {
+ const userRoles = userInfo[0].split('=')[1];
+
+ auditDesc = formatMessage(holders.updatedRol);
+ if (userRoles.trim()) {
+ auditDesc += userRoles;
} else {
- let currentActionDesc = '';
- if (currentActionURL && currentActionURL.lastIndexOf('/') !== -1) {
- currentActionDesc = currentActionURL.substring(currentActionURL.lastIndexOf('/') + 1).replace('_', ' ');
- currentActionDesc = Utils.toTitleCase(currentActionDesc);
- }
+ auditDesc += formatMessage(holders.member);
+ }
- let currentExtraInfoDesc = '';
- if (currentAudit.extra_info) {
- currentExtraInfoDesc = currentAudit.extra_info;
+ break;
+ }
+ case '/users/update_active': {
+ const updateType = userInfo[0].split('=')[0];
+ const updateField = userInfo[0].split('=')[1];
+
+ /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */
+ if (updateType === 'active') {
+ if (updateField === 'true') {
+ auditDesc = formatMessage(holders.accountActive);
+ } else if (updateField === 'false') {
+ auditDesc = formatMessage(holders.accountInactive);
+ }
- if (currentExtraInfoDesc.indexOf('=') !== -1) {
- currentExtraInfoDesc = currentExtraInfoDesc.substring(currentExtraInfoDesc.indexOf('=') + 1);
+ const actingUserInfo = userInfo[1].split('=');
+ if (actingUserInfo[0] === 'session_user') {
+ const actingUser = UserStore.getProfile(actingUserInfo[1]);
+ const user = UserStore.getCurrentUser();
+ if (user && actingUser && (Utils.isAdmin(user.roles) || Utils.isSystemAdmin(user.roles))) {
+ auditDesc += formatMessage(holders.by, {username: actingUser.username});
+ } else if (user && actingUser) {
+ auditDesc += formatMessage(holders.byAdmin);
}
}
- currentAuditDesc = currentActionDesc + ' ' + currentExtraInfoDesc;
+ } else if (updateType === 'session_id') {
+ auditDesc = formatMessage(holders.sessionRevoked, {sessionId: updateField});
}
- }
- const currentDate = new Date(currentAudit.create_at);
- let currentAuditInfo = currentDate.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + currentDate.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'});
+ break;
+ }
+ case '/users/send_password_reset':
+ auditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]});
+ break;
+ case '/users/reset_password':
+ if (userInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedReset);
+ } else if (userInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullReset);
+ }
- if (this.props.showUserId) {
- currentAuditInfo += ' | ' + formatMessage(holders.userId) + ': ' + currentAudit.user_id;
+ break;
+ case '/users/update_notify':
+ auditDesc = formatMessage(holders.updateGlobalNotifications);
+ break;
+ default:
+ break;
}
+ } else if (actionURL.indexOf('/hooks') === 0) {
+ const webhookInfo = audit.extra_info.split(' ');
+
+ switch (actionURL) {
+ case '/hooks/incoming/create':
+ if (webhookInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedWebhookCreate);
+ } else if (webhookInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.succcessfullWebhookCreate);
+ } else if (webhookInfo[0] === 'fail - bad channel permissions') {
+ auditDesc = formatMessage(holders.failedWebhookCreate);
+ }
- currentAuditInfo += ' | ' + currentAuditDesc;
+ break;
+ case '/hooks/incoming/delete':
+ if (webhookInfo[0] === 'attempt') {
+ auditDesc = formatMessage(holders.attemptedWebhookDelete);
+ } else if (webhookInfo[0] === 'success') {
+ auditDesc = formatMessage(holders.successfullWebhookDelete);
+ } else if (webhookInfo[0] === 'fail - inappropriate conditions') {
+ auditDesc = formatMessage(holders.failedWebhookDelete);
+ }
- return currentAuditInfo;
+ break;
+ default:
+ break;
+ }
+ } else {
+ switch (actionURL) {
+ case '/logout':
+ auditDesc = formatMessage(holders.logout);
+ break;
+ case '/verify_email':
+ auditDesc = formatMessage(holders.verified);
+ break;
+ default:
+ break;
+ }
}
- render() {
- var accessList = [];
- const {formatMessage} = this.props.intl;
- for (var i = 0; i < this.props.audits.length; i++) {
- const currentAudit = this.props.audits[i];
- const currentAuditInfo = this.formatAuditInfo(currentAudit);
-
- let moreInfo;
- if (!this.props.oneLine) {
- moreInfo = (
- <a
- href='#'
- className='theme'
- onClick={this.handleMoreInfo.bind(this, i)}
- >
- <FormattedMessage
- id='audit_table.moreInfo'
- defaultMessage='More info'
- />
- </a>
- );
+ /* If all else fails... */
+ if (!auditDesc) {
+ /* Currently not called anywhere */
+ if (audit.extra_info.indexOf('revoked_all=') >= 0) {
+ auditDesc = formatMessage(holders.revokedAll);
+ } else {
+ let actionDesc = '';
+ if (actionURL && actionURL.lastIndexOf('/') !== -1) {
+ actionDesc = actionURL.substring(actionURL.lastIndexOf('/') + 1).replace('_', ' ');
+ actionDesc = Utils.toTitleCase(actionDesc);
}
- if (this.state.moreInfo[i]) {
- if (!currentAudit.session_id) {
- currentAudit.session_id = 'N/A';
+ let extraInfoDesc = '';
+ if (audit.extra_info) {
+ extraInfoDesc = audit.extra_info;
- if (currentAudit.action.search('/users/login') >= 0) {
- if (currentAudit.extra_info === 'attempt') {
- currentAudit.session_id += formatMessage(holders.loginAttempt);
- } else {
- currentAudit.session_id += formatMessage(holders.loginFailure);
- }
- }
+ if (extraInfoDesc.indexOf('=') !== -1) {
+ extraInfoDesc = extraInfoDesc.substring(extraInfoDesc.indexOf('=') + 1);
}
-
- moreInfo = (
- <div>
- <div>
- <FormattedMessage
- id='audit_table.ip'
- defaultMessage='IP: {ip}'
- values={{
- ip: currentAudit.ip_address
- }}
- />
- </div>
- <div>
- <FormattedMessage
- id='audit_table.session'
- defaultMessage='Session ID: {id}'
- values={{
- id: currentAudit.session_id
- }}
- />
- </div>
- </div>
- );
}
-
- var divider = null;
- if (i < this.props.audits.length - 1) {
- divider = (<div className='divider-light'></div>);
- }
-
- accessList[i] = (
- <div
- key={'accessHistoryEntryKey' + i}
- className='access-history__table'
- >
- <div className='access__report'>
- <div className='report__time'>{currentAuditInfo}</div>
- <div className='report__info'>
- {moreInfo}
- </div>
- {divider}
- </div>
- </div>
- );
+ auditDesc = actionDesc + ' ' + extraInfoDesc;
}
-
- return <form role='form'>{accessList}</form>;
}
-}
-AuditTable.propTypes = {
- intl: intlShape.isRequired,
- audits: React.PropTypes.array.isRequired,
- oneLine: React.PropTypes.bool,
- showUserId: React.PropTypes.bool
-};
+ const date = new Date(audit.create_at);
+ let auditInfo = {};
+ auditInfo.timestamp = date.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + date.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'});
+ auditInfo.userId = audit.user_id;
+ auditInfo.desc = auditDesc;
+ auditInfo.ip = audit.ip_address;
+ auditInfo.sessionId = audit.session_id;
-export default injectIntl(AuditTable);
+ return auditInfo;
+}
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
index 8c49315e7..9e7c67515 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -51,6 +51,7 @@ class CreateComment extends React.Component {
this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
this.handleUploadError = this.handleUploadError.bind(this);
@@ -74,6 +75,8 @@ class CreateComment extends React.Component {
componentDidMount() {
PreferenceStore.addChangeListener(this.onPreferenceChange);
window.addEventListener('resize', this.handleResize);
+
+ this.refs.textbox.focus();
}
componentWillUnmount() {
PreferenceStore.removeChangeListener(this.onPreferenceChange);
@@ -94,6 +97,10 @@ class CreateComment extends React.Component {
$('.post-right__scroll').perfectScrollbar('update');
}
}
+
+ if (prevProps.rootId !== this.props.rootId) {
+ this.refs.textbox.focus();
+ }
}
handleSubmit(e) {
e.preventDefault();
@@ -218,6 +225,9 @@ class CreateComment extends React.Component {
});
}
}
+ handleUploadClick() {
+ this.refs.textbox.focus();
+ }
handleUploadStart(clientIds) {
let draft = PostStore.getCommentDraft(this.props.rootId);
@@ -225,6 +235,10 @@ class CreateComment extends React.Component {
PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({uploadsInProgress: draft.uploadsInProgress});
+
+ // this is a bit redundant with the code that sets focus when the file input is clicked,
+ // but this also resets the focus after a drag and drop
+ this.refs.textbox.focus();
}
handleFileUploadComplete(filenames, clientIds) {
let draft = PostStore.getCommentDraft(this.props.rootId);
@@ -365,6 +379,7 @@ class CreateComment extends React.Component {
<FileUpload
ref='fileUpload'
getFileCount={this.getFileCount}
+ onClick={this.handleUploadClick}
onUploadStart={this.handleUploadStart}
onFileUpload={this.handleFileUploadComplete}
onUploadError={this.handleUploadError}
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index 20892898e..6ea80cd13 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -40,10 +40,6 @@ const holders = defineMessages({
write: {
id: 'create_post.write',
defaultMessage: 'Write a message...'
- },
- deleteMsg: {
- id: 'create_post.deleteMsg',
- defaultMessage: '(message deleted)'
}
});
@@ -57,7 +53,7 @@ class CreatePost extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.postMsgKeyPress = this.postMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
- this.resizePostHolder = this.resizePostHolder.bind(this);
+ this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
this.handleUploadError = this.handleUploadError.bind(this);
@@ -66,11 +62,9 @@ class CreatePost extends React.Component {
this.onPreferenceChange = this.onPreferenceChange.bind(this);
this.getFileCount = this.getFileCount.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
- this.handleResize = this.handleResize.bind(this);
this.sendMessage = this.sendMessage.bind(this);
PostStore.clearDraftUploads();
- PostStore.deleteMessage(this.props.intl.formatMessage(holders.deleteMsg));
const draft = this.getCurrentDraft();
@@ -81,34 +75,10 @@ class CreatePost extends React.Component {
previews: draft.previews,
submitting: false,
initialText: draft.messageText,
- windowWidth: Utils.windowWidth(),
- windowHeight: Utils.windowHeight(),
ctrlSend: false,
showTutorialTip: false
};
}
- handleResize() {
- this.setState({
- windowWidth: Utils.windowWidth(),
- windowHeight: Utils.windowHeight()
- });
- }
- componentDidUpdate(prevProps, prevState) {
- if (prevState.previews.length !== this.state.previews.length) {
- this.resizePostHolder();
- return;
- }
-
- if (prevState.uploadsInProgress !== this.state.uploadsInProgress) {
- this.resizePostHolder();
- return;
- }
-
- if (prevState.windowWidth !== this.state.windowWidth || prevState.windowHeight !== this.state.windowHeight) {
- this.resizePostHolder();
- return;
- }
- }
getCurrentDraft() {
const draft = PostStore.getCurrentDraft();
const safeDraft = {previews: [], messageText: '', uploadsInProgress: []};
@@ -245,10 +215,8 @@ class CreatePost extends React.Component {
draft.message = messageText;
PostStore.storeCurrentDraft(draft);
}
- resizePostHolder() {
- if (this.state.windowWidth > 960) {
- $('#post_textbox').focus();
- }
+ handleUploadClick() {
+ this.refs.textbox.focus();
}
handleUploadStart(clientIds, channelId) {
const draft = PostStore.getDraft(channelId);
@@ -257,6 +225,10 @@ class CreatePost extends React.Component {
PostStore.storeDraft(channelId, draft);
this.setState({uploadsInProgress: draft.uploadsInProgress});
+
+ // this is a bit redundant with the code that sets focus when the file input is clicked,
+ // but this also resets the focus after a drag and drop
+ this.refs.textbox.focus();
}
handleFileUploadComplete(filenames, clientIds, channelId) {
const draft = PostStore.getDraft(channelId);
@@ -333,13 +305,16 @@ class CreatePost extends React.Component {
componentDidMount() {
ChannelStore.addChangeListener(this.onChange);
PreferenceStore.addChangeListener(this.onPreferenceChange);
- this.resizePostHolder();
- window.addEventListener('resize', this.handleResize);
+ this.refs.textbox.focus();
+ }
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.channelId !== this.state.channelId) {
+ this.refs.textbox.focus();
+ }
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onChange);
PreferenceStore.removeChangeListener(this.onPreferenceChange);
- window.removeEventListener('resize', this.handleResize);
}
onChange() {
const channelId = ChannelStore.getCurrentId();
@@ -462,7 +437,6 @@ class CreatePost extends React.Component {
onUserInput={this.handleUserInput}
onKeyPress={this.postMsgKeyPress}
onKeyDown={this.handleKeyDown}
- onHeightChange={this.resizePostHolder}
messageText={this.state.messageText}
createMessage={this.props.intl.formatMessage(holders.write)}
channelId={this.state.channelId}
@@ -472,6 +446,7 @@ class CreatePost extends React.Component {
<FileUpload
ref='fileUpload'
getFileCount={this.getFileCount}
+ onClick={this.handleUploadClick}
onUploadStart={this.handleUploadStart}
onFileUpload={this.handleFileUploadComplete}
onUploadError={this.handleUploadError}
@@ -506,4 +481,4 @@ CreatePost.propTypes = {
intl: intlShape.isRequired
};
-export default injectIntl(CreatePost); \ No newline at end of file
+export default injectIntl(CreatePost);
diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx
index 34fd724f5..9d7dcb3e5 100644
--- a/web/react/components/delete_post_modal.jsx
+++ b/web/react/components/delete_post_modal.jsx
@@ -88,7 +88,7 @@ export default class DeletePostModal extends React.Component {
}
}
- PostStore.removePost(this.state.post.id, this.state.post.channel_id);
+ PostStore.deletePost(this.state.post);
AsyncClient.getPosts(this.state.post.channel_id);
},
(err) => {
diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx
index 746289653..f5c32c825 100644
--- a/web/react/components/file_upload.jsx
+++ b/web/react/components/file_upload.jsx
@@ -310,6 +310,7 @@ class FileUpload extends React.Component {
ref='fileInput'
type='file'
onChange={this.handleChange}
+ onClick={this.props.onClick}
multiple={multiple}
accept={accept}
/>
@@ -322,6 +323,7 @@ FileUpload.propTypes = {
intl: intlShape.isRequired,
onUploadError: React.PropTypes.func,
getFileCount: React.PropTypes.func,
+ onClick: React.PropTypes.func,
onFileUpload: React.PropTypes.func,
onUploadStart: React.PropTypes.func,
onTextDrop: React.PropTypes.func,
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index 16f8528b2..d71ac6ec7 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -44,7 +44,6 @@ class PostBody extends React.Component {
this.state = {
links: linkData.links,
- message: linkData.text,
post: this.props.post,
hasUserProfiles: profiles && Object.keys(profiles).length > 1
};
@@ -106,7 +105,9 @@ class PostBody extends React.Component {
if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) {
this.embed = this.createEmbed(linkData.links[0]);
}
- this.setState({links: linkData.links, message: linkData.text});
+ this.setState({
+ links: linkData.links
+ });
}
createEmbed(link) {
@@ -310,6 +311,23 @@ class PostBody extends React.Component {
);
}
+ let message;
+ if (this.props.post.state === Constants.POST_DELETED) {
+ message = (
+ <FormattedMessage
+ id='post_body.deleted'
+ defaultMessage='(message deleted)'
+ />
+ );
+ } else {
+ message = (
+ <span
+ onClick={TextFormatting.handleClick}
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message)}}
+ />
+ );
+ }
+
return (
<div>
{comment}
@@ -320,11 +338,7 @@ class PostBody extends React.Component {
className={postClass}
>
{loading}
- <span
- ref='message_span'
- onClick={TextFormatting.handleClick}
- dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}}
- />
+ {message}
</div>
<PostBodyAdditionalContent
post={this.state.post}
@@ -346,4 +360,4 @@ PostBody.propTypes = {
handleCommentClick: React.PropTypes.func.isRequired
};
-export default injectIntl(PostBody); \ No newline at end of file
+export default injectIntl(PostBody);
diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx
index ddb393520..b1bc8ca14 100644
--- a/web/react/components/post_info.jsx
+++ b/web/react/components/post_info.jsx
@@ -23,13 +23,14 @@ export default class PostInfo extends React.Component {
};
this.handlePermalinkCopy = this.handlePermalinkCopy.bind(this);
+ this.removePost = this.removePost.bind(this);
}
createDropdown() {
var post = this.props.post;
var isOwner = UserStore.getCurrentId() === post.user_id;
var isAdmin = Utils.isAdmin(UserStore.getCurrentUser().roles);
- if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || post.state === Constants.POST_DELETED) {
+ if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || Utils.isPostEphemeral(post)) {
return '';
}
@@ -166,6 +167,25 @@ export default class PostInfo extends React.Component {
this.setState({copiedLink: false});
}
}
+ removePost() {
+ EventHelpers.emitRemovePost(this.props.post);
+ }
+ createRemovePostButton(post) {
+ if (!Utils.isPostEphemeral(post)) {
+ return null;
+ }
+
+ return (
+ <a
+ href='#'
+ className='post__remove theme'
+ type='button'
+ onClick={this.removePost}
+ >
+ {'×'}
+ </a>
+ );
+ }
render() {
var post = this.props.post;
var comments = '';
@@ -178,7 +198,7 @@ export default class PostInfo extends React.Component {
commentCountText = '';
}
- if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) {
+ if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && !Utils.isPostEphemeral(post)) {
comments = (
<a
href='#'
@@ -264,6 +284,7 @@ export default class PostInfo extends React.Component {
>
{permalinkOverlay}
</Overlay>
+ {this.createRemovePostButton(post)}
</li>
</ul>
);
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
index 00e5ace98..ec299087d 100644
--- a/web/react/components/textbox.jsx
+++ b/web/react/components/textbox.jsx
@@ -20,6 +20,7 @@ export default class Textbox extends React.Component {
constructor(props) {
super(props);
+ this.focus = this.focus.bind(this);
this.getStateFromStores = this.getStateFromStores.bind(this);
this.onRecievedError = this.onRecievedError.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
@@ -81,6 +82,10 @@ export default class Textbox extends React.Component {
}
}
+ focus() {
+ this.refs.message.getTextbox().focus();
+ }
+
resize() {
const textbox = this.refs.message.getTextbox();
const $textbox = $(textbox);
@@ -90,8 +95,6 @@ export default class Textbox extends React.Component {
const borders = parseInt($textbox.css('border-bottom-width'), 10) + parseInt($textbox.css('border-top-width'), 10);
const maxHeight = parseInt($textbox.css('max-height'), 10) - borders;
- const prevHeight = $textbox.height();
-
// set the height to auto and remove the scrollbar so we can get the actual size of the contents
$textbox.css('height', 'auto').css('overflow-y', 'hidden');
@@ -116,10 +119,6 @@ export default class Textbox extends React.Component {
if (this.state.preview) {
$(ReactDOM.findDOMNode(this.refs.preview)).height(height + borders);
}
-
- if (height !== prevHeight && this.props.onHeightChange) {
- this.props.onHeightChange();
- }
}
showPreview(e) {
@@ -211,7 +210,6 @@ Textbox.propTypes = {
messageText: React.PropTypes.string.isRequired,
onUserInput: React.PropTypes.func.isRequired,
onKeyPress: React.PropTypes.func.isRequired,
- onHeightChange: React.PropTypes.func,
createMessage: React.PropTypes.string.isRequired,
onKeyDown: React.PropTypes.func,
supportsCommands: React.PropTypes.bool.isRequired
diff --git a/web/react/components/user_settings/manage_command_hooks.jsx b/web/react/components/user_settings/manage_command_hooks.jsx
index bcf0a6c82..b2fc0a4e1 100644
--- a/web/react/components/user_settings/manage_command_hooks.jsx
+++ b/web/react/components/user_settings/manage_command_hooks.jsx
@@ -39,6 +39,14 @@ const holders = defineMessages({
adUrlPlaceholder: {
id: 'user.settings.cmds.url.placeholder',
defaultMessage: 'Must start with http:// or https://'
+ },
+ autocompleteYes: {
+ id: 'user.settings.cmds.auto_complete.yes',
+ defaultMessage: 'yes'
+ },
+ autocompleteNo: {
+ id: 'user.settings.cmds.auto_complete.no',
+ defaultMessage: 'no'
}
});
@@ -295,7 +303,7 @@ export default class ManageCommandCmds extends React.Component {
id='user.settings.cmds.auto_complete'
defaultMessage='Auto Complete: '
/>
- </strong><span className='word-break--all'>{cmd.auto_complete ? 'yes' : 'no'}</span>
+ </strong><span className='word-break--all'>{cmd.auto_complete ? this.props.intl.formatMessage(holders.autocompleteYes) : this.props.intl.formatMessage(holders.autocompleteNo)}</span>
</div>
<div className='padding-top x2'>
<strong>
@@ -414,7 +422,12 @@ export default class ManageCommandCmds extends React.Component {
id='user.settings.cmds.add_desc'
defaultMessage='Create commands to send message events to an external integration. Please see <a href="http://mattermost.org/commands">http://mattermost.org/commands</a> to learn more.'
/>
- <div><label className='control-label padding-top x2'>{'Add a new command'}</label></div>
+ <div><label className='control-label padding-top x2'>
+ <FormattedMessage
+ id='user.settings.cmds.add_new'
+ defaultMessage='Add a new command'
+ />
+ </label></div>
<div className='padding-top divider-light'></div>
<div className='padding-top'>
<div className='padding-top x2'>
@@ -433,7 +446,12 @@ export default class ManageCommandCmds extends React.Component {
placeholder={this.props.intl.formatMessage(holders.addDisplayNamePlaceholder)}
/>
</div>
- <div className='padding-top'>{'Command display name.'}</div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.cmd_display_name'
+ defaultMessage='Command display name.'
+ />
+ </div>
</div>
<div className='padding-top x2'>
<label className='control-label'>
@@ -638,7 +656,10 @@ export default class ManageCommandCmds extends React.Component {
disabled={disableButton}
onClick={this.addNewCmd}
>
- {'Add'}
+ <FormattedMessage
+ id='user.settings.cmds.add'
+ defaultMessage='Add'
+ />
</a>
</div>
</div>
diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx
index 91a03eb70..786e53f10 100644
--- a/web/react/components/user_settings/user_settings_notifications.jsx
+++ b/web/react/components/user_settings/user_settings_notifications.jsx
@@ -294,7 +294,7 @@ class NotificationsTab extends React.Component {
<span>
<FormattedMessage
id='user.settings.notifications.info'
- defaultMessage='Desktop notification sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
+ defaultMessage='Desktop notifications are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
/>
</span>
);
@@ -395,8 +395,8 @@ class NotificationsTab extends React.Component {
const extraInfo = (
<span>
<FormattedMessage
- id='user.settings.notifications.info'
- defaultMessage='Desktop notification sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
+ id='user.settings.notifications.sounds_info'
+ defaultMessage='Desktop notifications sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.'
/>
</span>
);
diff --git a/web/react/dispatcher/event_helpers.jsx b/web/react/dispatcher/event_helpers.jsx
index 5eb319320..c1041e438 100644
--- a/web/react/dispatcher/event_helpers.jsx
+++ b/web/react/dispatcher/event_helpers.jsx
@@ -9,6 +9,7 @@ import Constants from '../utils/constants.jsx';
const ActionTypes = Constants.ActionTypes;
import * as AsyncClient from '../utils/async_client.jsx';
import * as Client from '../utils/client.jsx';
+import * as Utils from '../utils/utils.jsx';
export function emitChannelClickEvent(channel) {
AsyncClient.getChannels(true);
@@ -180,3 +181,27 @@ export function emitPreferenceChangedEvent(preference) {
preference
});
}
+
+export function emitRemovePost(post) {
+ AppDispatcher.handleViewAction({
+ type: Constants.ActionTypes.REMOVE_POST,
+ post
+ });
+}
+
+export function sendEphemeralPost(message, channelId) {
+ const timestamp = Utils.getTimestamp();
+ const post = {
+ id: Utils.generateId(),
+ user_id: '0',
+ channel_id: channelId || ChannelStore.getCurrentId(),
+ message,
+ type: Constants.POST_TYPE_EPHEMERAL,
+ create_at: timestamp,
+ update_at: timestamp,
+ filenames: [],
+ props: {}
+ };
+
+ emitPostRecievedEvent(post);
+}
diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx
index 08ffef822..8ff58f685 100644
--- a/web/react/stores/post_store.jsx
+++ b/web/react/stores/post_store.jsx
@@ -57,6 +57,7 @@ class PostStoreClass extends EventEmitter {
this.clearFocusedPost = this.clearFocusedPost.bind(this);
this.clearChannelVisibility = this.clearChannelVisibility.bind(this);
+ this.deletePost = this.deletePost.bind(this);
this.removePost = this.removePost.bind(this);
this.getPendingPosts = this.getPendingPosts.bind(this);
@@ -65,10 +66,6 @@ class PostStoreClass extends EventEmitter {
this.clearPendingPosts = this.clearPendingPosts.bind(this);
this.updatePendingPost = this.updatePendingPost.bind(this);
- this.storeUnseenDeletedPost = this.storeUnseenDeletedPost.bind(this);
- this.getUnseenDeletedPosts = this.getUnseenDeletedPosts.bind(this);
- this.clearUnseenDeletedPosts = this.clearUnseenDeletedPosts.bind(this);
-
// These functions are bad and work should be done to remove this system when the RHS dies
this.storeSelectedPost = this.storeSelectedPost.bind(this);
this.getSelectedPost = this.getSelectedPost.bind(this);
@@ -211,28 +208,6 @@ class PostStoreClass extends EventEmitter {
postList.order = this.postsInfo[id].pendingPosts.order.concat(postList.order);
}
- // Add deleted posts
- if (this.postsInfo[id].hasOwnProperty('deletedPosts')) {
- Object.assign(postList.posts, this.postsInfo[id].deletedPosts);
-
- for (const postID in this.postsInfo[id].deletedPosts) {
- if (this.postsInfo[id].deletedPosts.hasOwnProperty(postID)) {
- postList.order.push(postID);
- }
- }
-
- // Merge would be faster
- postList.order.sort((a, b) => {
- if (postList.posts[a].create_at > postList.posts[b].create_at) {
- return -1;
- }
- if (postList.posts[a].create_at < postList.posts[b].create_at) {
- return 1;
- }
- return 0;
- });
- }
-
return postList;
}
@@ -286,15 +261,6 @@ class PostStoreClass extends EventEmitter {
if (combinedPosts.order.indexOf(pid) === -1) {
combinedPosts.order.push(pid);
}
- } else {
- if (pid in combinedPosts.posts) {
- Reflect.deleteProperty(combinedPosts.posts, pid);
- }
-
- const index = combinedPosts.order.indexOf(pid);
- if (index !== -1) {
- combinedPosts.order.splice(index, 1);
- }
}
}
}
@@ -365,6 +331,22 @@ class PostStoreClass extends EventEmitter {
this.postsInfo[id].atBottom = atBottom;
}
+ deletePost(post) {
+ const postList = this.postsInfo[post.channel_id].postList;
+
+ if (isPostListNull(postList)) {
+ return;
+ }
+
+ if (post.id in postList.posts) {
+ // make sure to copy the post so that component state changes work properly
+ postList.posts[post.id] = Object.assign({}, post, {
+ state: Constants.POST_DELETED,
+ filenames: []
+ });
+ }
+ }
+
removePost(post) {
const channelId = post.channel_id;
this.makePostsInfo(channelId);
@@ -439,37 +421,6 @@ class PostStoreClass extends EventEmitter {
this.emitChange();
}
- storeUnseenDeletedPost(post) {
- let posts = this.getUnseenDeletedPosts(post.channel_id);
-
- if (!posts) {
- posts = {};
- }
-
- post.message = this.delete_message;
- post.state = Constants.POST_DELETED;
- post.filenames = [];
-
- posts[post.id] = post;
-
- this.makePostsInfo(post.channel_id);
- this.postsInfo[post.channel_id].deletedPosts = posts;
- }
-
- getUnseenDeletedPosts(channelId) {
- if (this.postsInfo.hasOwnProperty(channelId)) {
- return this.postsInfo[channelId].deletedPosts;
- }
-
- return null;
- }
-
- clearUnseenDeletedPosts(channelId) {
- if (this.postsInfo.hasOwnProperty(channelId)) {
- Reflect.deleteProperty(this.postsInfo[channelId], 'deletedPosts');
- }
- }
-
storeSelectedPost(postList) {
this.selectedPost = postList;
}
@@ -581,9 +532,6 @@ class PostStoreClass extends EventEmitter {
return commentCount;
}
- deleteMessage(msg) {
- this.delete_message = msg;
- }
}
var PostStore = new PostStoreClass();
@@ -615,7 +563,6 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => {
case ActionTypes.CLICK_CHANNEL:
PostStore.clearFocusedPost();
PostStore.clearChannelVisibility(action.id, true);
- PostStore.clearUnseenDeletedPosts(action.prev);
break;
case ActionTypes.CREATE_POST:
PostStore.storePendingPost(action.post);
@@ -623,7 +570,10 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => {
PostStore.jumpPostsViewToBottom();
break;
case ActionTypes.POST_DELETED:
- PostStore.storeUnseenDeletedPost(action.post);
+ PostStore.deletePost(action.post);
+ PostStore.emitChange();
+ break;
+ case ActionTypes.REMOVE_POST:
PostStore.removePost(action.post);
PostStore.emitChange();
break;
diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx
index 744c2c8e5..9c3270f68 100644
--- a/web/react/stores/socket_store.jsx
+++ b/web/react/stores/socket_store.jsx
@@ -64,6 +64,9 @@ class SocketStoreClass extends EventEmitter {
ErrorStore.storeLastError(null);
ErrorStore.emitChange();
}
+
+ AsyncClient.getChannels();
+ AsyncClient.getPosts(ChannelStore.getCurrentId());
}
this.failCount = 0;
@@ -71,6 +74,16 @@ class SocketStoreClass extends EventEmitter {
conn.onclose = () => {
conn = null;
+
+ if (this.failCount === 0) {
+ console.log('websocket closed'); //eslint-disable-line no-console
+ }
+
+ this.failCount = this.failCount + 1;
+
+ ErrorStore.storeLastError({connErrorCount: this.failCount, message: this.translations.socketError});
+ ErrorStore.emitChange();
+
setTimeout(
() => {
this.initialize();
@@ -80,14 +93,10 @@ class SocketStoreClass extends EventEmitter {
};
conn.onerror = (evt) => {
- if (this.failCount === 0) {
- console.log('websocket error ' + evt); //eslint-disable-line no-console
+ if (this.failCount <= 1) {
+ console.log('websocket error'); //eslint-disable-line no-console
+ console.log(evt); //eslint-disable-line no-console
}
-
- this.failCount = this.failCount + 1;
-
- ErrorStore.storeLastError({connErrorCount: this.failCount, message: this.translations.socketError});
- ErrorStore.emitChange();
};
conn.onmessage = (evt) => {
@@ -109,6 +118,7 @@ class SocketStoreClass extends EventEmitter {
handleMessage(msg) {
switch (msg.action) {
case SocketEvents.POSTED:
+ case SocketEvents.EPHEMERAL_MESSAGE:
handleNewPostEvent(msg, this.translations);
break;
@@ -179,7 +189,6 @@ function handleNewPostEvent(msg, translations) {
mentions = JSON.parse(msg.props.mentions);
}
- const channelType = msgProps.channel_type;
const channel = ChannelStore.get(msg.channel_id);
const user = UserStore.getCurrentUser();
const member = ChannelStore.getMember(msg.channel_id);
@@ -191,7 +200,7 @@ function handleNewPostEvent(msg, translations) {
if (notifyLevel === 'none') {
return;
- } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channelType !== Constants.DM_CHANNEL) {
+ } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== Constants.DM_CHANNEL) {
return;
}
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 11a8da669..c1bd41b88 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -12,6 +12,7 @@ export default {
LEAVE_CHANNEL: null,
CREATE_POST: null,
POST_DELETED: null,
+ REMOVE_POST: null,
RECIEVED_CHANNELS: null,
RECIEVED_CHANNEL: null,
@@ -78,7 +79,8 @@ export default {
USER_ADDED: 'user_added',
USER_REMOVED: 'user_removed',
TYPING: 'typing',
- PREFERENCE_CHANGED: 'preference_changed'
+ PREFERENCE_CHANGED: 'preference_changed',
+ EPHEMERAL_MESSAGE: 'ephemeral_message'
},
//SPECIAL_MENTIONS: ['all', 'channel'],
@@ -126,6 +128,7 @@ export default {
POST_LOADING: 'loading',
POST_FAILED: 'failed',
POST_DELETED: 'deleted',
+ POST_TYPE_EPHEMERAL: 'system_ephemeral',
POST_TYPE_JOIN_LEAVE: 'system_join_leave',
SYSTEM_MESSAGE_PREFIX: 'system_',
SYSTEM_MESSAGE_PROFILE_NAME: 'System',
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 494c38bdb..e8cfc82bc 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -1355,3 +1355,7 @@ export function languages() {
]
);
}
+
+export function isPostEphemeral(post) {
+ return post.type === Constants.POST_TYPE_EPHEMERAL || post.state === Constants.POST_DELETED;
+}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index 2ff49c9b7..77b66a1a8 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -408,7 +408,7 @@ body.ios {
@include legacy-pie-clearfix;
&:hover {
- .dropdown, .comment-icon__container, .post__reply {
+ .dropdown, .comment-icon__container, .post__reply, .post__remove {
visibility: visible;
}
.permalink-icon {
@@ -646,6 +646,13 @@ body.ios {
}
}
+ .post__remove {
+ display: inline-block;
+ visibility: hidden;
+ margin-right: 5px;
+ top: -1px;
+ }
+
.post__body {
word-wrap: break-word;
padding: 0.2em 0.5em 0em;
diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json
index 888af6cb6..1897988f9 100644
--- a/web/static/i18n/en.json
+++ b/web/static/i18n/en.json
@@ -8,6 +8,54 @@
"about.date": "Build Date:",
"about.hash": "Build Hash:",
"about.close": "Close",
+ "audit_table.sessionRevoked": "The session with id {sessionId} was revoked",
+ "audit_table.channelCreated": "Created the {channelName} channel/group",
+ "audit_table.establishedDM": "Established a direct message channel with {username}",
+ "audit_table.nameUpdated": "Updated the {channelName} channel/group name",
+ "audit_table.headerUpdated": "Updated the {channelName} channel/group header",
+ "audit_table.channelDeleted": "Deleted the channel/group with the URL {url}",
+ "audit_table.userAdded": "Added {username} to the {channelName} channel/group",
+ "audit_table.userRemoved": "Removed {username} to the {channelName} channel/group",
+ "audit_table.attemptedRegisterApp": "Attempted to register a new OAuth Application with ID {id}",
+ "audit_table.attemptedAllowOAuthAccess": "Attempted to allow a new OAuth service access",
+ "audit_table.successfullOAuthAccess": "Successfully gave a new OAuth service access",
+ "audit_table.failedOAuthAccess": "Failed to allow a new OAuth service access - the redirect URI did not match the previously registered callback",
+ "audit_table.attemptedOAuthToken": "Attempted to get an OAuth access token",
+ "audit_table.successfullOAuthToken": "Successfully added a new OAuth service",
+ "audit_table.oauthTokenFailed": "Failed to get an OAuth access token - {token}",
+ "audit_table.attemptedLogin": "Attempted to login",
+ "audit_table.successfullLogin": "Successfully logged in",
+ "audit_table.failedLogin": "FAILED login attempt",
+ "audit_table.updatePicture": "Updated your profile picture",
+ "audit_table.updateGeneral": "Updated the general settings of your account",
+ "audit_table.attemptedPassword": "Attempted to change password",
+ "audit_table.successfullPassword": "Successfully changed password",
+ "audit_table.failedPassword": "Failed to change password - tried to update user password who was logged in through oauth",
+ "audit_table.updatedRol": "Updated user role(s) to ",
+ "audit_table.member": "member",
+ "audit_table.accountActive": "Account made active",
+ "audit_table.accountInactive": "Account made inactive",
+ "audit_table.by": " by {username}",
+ "audit_table.byAdmin": " by an admin",
+ "audit_table.sentEmail": "Sent an email to {email} to reset your password",
+ "audit_table.attemptedReset": "Attempted to reset password",
+ "audit_table.successfullReset": "Successfully reset password",
+ "audit_table.updateGlobalNotifications": "Updated your global notification settings",
+ "audit_table.attemptedWebhookCreate": "Attempted to create a webhook",
+ "audit_table.successfullWebhookCreate": "Successfully created a webhook",
+ "audit_table.failedWebhookCreate": "Failed to create a webhook - bad channel permissions",
+ "audit_table.attemptedWebhookDelete": "Attempted to delete a webhook",
+ "audit_table.successfullWebhookDelete": "Successfully deleted a webhook",
+ "audit_table.failedWebhookDelete": "Failed to delete a webhook - inappropriate conditions",
+ "audit_table.logout": "Logged out of your account",
+ "audit_table.verified": "Sucessfully verified your email address",
+ "audit_table.revokedAll": "Revoked all current sessions for the team",
+ "audit_table.loginAttempt": " (Login attempt)",
+ "audit_table.loginFailure": " (Login failure)",
+ "audit_table.moreInfo": "More info",
+ "audit_table.ip": "IP Address",
+ "audit_table.session": "Session ID",
+ "audit_table.userId": "User ID",
"access_history.title": "Access History",
"activity_log_modal.iphoneNativeApp": "iPhone Native App",
"activity_log_modal.androidNativeApp": "Android Native App",
@@ -33,7 +81,6 @@
"admin.sidebar.statistics": "- Statistics",
"admin.sidebar.ldap": "LDAP Settings",
"admin.sidebar.license": "Edition and License",
- "admin.sidebar.audits": "Audits",
"admin.sidebar.reports": "SITE REPORTS",
"admin.sidebar.view_statistics": "View Statistics",
"admin.sidebar.settings": "SETTINGS",
@@ -50,6 +97,8 @@
"admin.sidebar.teams": "TEAMS ({count})",
"admin.sidebar.other": "OTHER",
"admin.sidebar.logs": "Logs",
+ "admin.sidebar.audits": "Compliance and Auditing",
+ "admin.analytics.loading": "Loading...",
"admin.analytics.totalUsers": "Total Users",
"admin.analytics.publicChannels": "Public Channels",
"admin.analytics.privateGroups": "Private Groups",
@@ -67,8 +116,6 @@
"admin.analytics.recentActive": "Recent Active Users",
"admin.analytics.newlyCreated": "Newly Created Users",
"admin.analytics.title": "Statistics for {title}",
- "admin.audits.title": "Server Audits",
- "admin.audits.reload": "Reload",
"admin.email.notificationDisplayExample": "Ex: \"Mattermost Notification\", \"System\", \"No-Reply\"",
"admin.email.notificationEmailExample": "Ex: \"mattermost@yourcompany.com\", \"admin@yourcompany.com\"",
"admin.email.smtpUsernameExample": "Ex: \"admin@yourcompany.com\", \"AKIADTOVBGERKLCBV\"",
@@ -255,7 +302,7 @@
"admin.license.removing": "Removing License...",
"admin.license.uploading": "Uploading License...",
"admin.license.enterpriseEdition": "Mattermost Enterprise Edition. Designed for enterprise-scale communication.",
- "admin.license.entrepriseType": "<div><p>This compiled release of Mattermost platform is provided under a <a href=\"http://mattermost.com\" target=\"_blank\">commercial license</a>\n from Mattermost, Inc. based on your subscription level and is subject to the <a href=\"{terms}\" target=\"_blank\">Terms of Service.</a></p>\n <p>Your subscription details are as follows:</p>\n Name: {name}<br />\n Company or organization name: {company}<br/>\n Number of users: {users}<br/>\n License issued: {issued}<br/>\n Start date of license: {start}<br/>\n Expiry date of license: {expires}<br/>\n LDAP: {ldap}<br/></div>",
+ "admin.license.enterpriseType": "<div><p>This compiled release of Mattermost platform is provided under a <a href=\"http://mattermost.com\" target=\"_blank\">commercial license</a> from Mattermost, Inc. based on your subscription level and is subject to the <a href=\"{terms}\" target=\"_blank\">Terms of Service.</a></p><p>Your subscription details are as follows:</p>Name: {name}<br />Company or organization name: {company}<br/>Number of users: {users}<br/>License issued: {issued}<br/>Start date of license: {start}<br/>Expiry date of license: {expires}<br/>LDAP: {ldap}<br/></div>",
"admin.license.keyRemove": "Remove Enterprise License and Downgrade Server",
"admin.licence.keyMigration": "If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start,\n <a href=\"http://mattermost.com\" target=\"_blank\">disable all Enterprise Edition features on this server</a>.\n This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.",
"admin.license.teamEdition": "Mattermost Team Edition. Designed for teams from 5 to 50 users.",
@@ -293,6 +340,8 @@
"admin.log.save": "Save",
"admin.logs.title": "Server Logs",
"admin.logs.reload": "Reload",
+ "admin.audits.title": "User Activity",
+ "admin.audits.reload": "Reload",
"admin.privacy.saving": "Saving Config...",
"admin.privacy.title": "Privacy Settings",
"admin.privacy.showEmailTitle": "Show Email Address: ",
@@ -355,7 +404,7 @@
"admin.service.cmdsDesc": "When true, user created slash commands will be allowed.",
"admin.service.integrationAdmin": "Enable Integrations for Admin Only: ",
"admin.service.integrationAdminDesc": "When true, user created integrations can only be created by admins.",
- "admin.service.overrideTitle": "Enable Overriding Usernames from Webhooks and Salsh Commands: ",
+ "admin.service.overrideTitle": "Enable Overriding Usernames from Webhooks and Slash Commands: ",
"admin.service.overrideDescription": "When true, webhooks and slash commands will be allowed to change the username they are posting as. Note, combined with allowing icon overriding, this could open users up to phishing attacks.",
"admin.service.iconTitle": "Enable Overriding Icon from Webhooks and Slash Commands: ",
"admin.service.iconDescription": "When true, webhooks and slash commands will be allowed to change the icon they post with. Note, combined with allowing username overriding, this could open users up to phishing attacks.",
@@ -440,54 +489,6 @@
"admin.user_item.makeActive": "Make Active",
"admin.user_item.makeInactive": "Make Inactive",
"admin.user_item.resetPwd": "Reset Password",
- "audit_table.sessionRevoked": "The session with id {sessionId} was revoked",
- "audit_table.channelCreated": "Created the {channelName} channel/group",
- "audit_table.establishedDM": "Established a direct message channel with {username}",
- "audit_table.nameUpdated": "Updated the {channelName} channel/group name",
- "audit_table.headerUpdated": "Updated the {channelName} channel/group header",
- "audit_table.channelDeleted": "Deleted the channel/group with the URL {url}",
- "audit_table.userAdded": "Added {username} to the {channelName} channel/group",
- "audit_table.userRemoved": "Removed {username} to the {channelName} channel/group",
- "audit_table.attemptedRegisterApp": "Attempted to register a new OAuth Application with ID {id}",
- "audit_table.attemptedAllowOAuthAccess": "Attempted to allow a new OAuth service access",
- "audit_table.successfullOAuthAccess": "Successfully gave a new OAuth service access",
- "audit_table.failedOAuthAccess": "Failed to allow a new OAuth service access - the redirect URI did not match the previously registered callback",
- "audit_table.attemptedOAuthToken": "Attempted to get an OAuth access token",
- "audit_table.successfullOAuthToken": "Successfully added a new OAuth service",
- "audit_table.oauthTokenFailed": "Failed to get an OAuth access token - {token}",
- "audit_table.attemptedLogin": "Attempted to login",
- "audit_table.successfullLogin": "Successfully logged in",
- "audit_table.failedLogin": "FAILED login attempt",
- "audit_table.updatePicture": "Updated your profile picture",
- "audit_table.updateGeneral": "Updated the general settings of your account",
- "audit_table.attemptedPassword": "Attempted to change password",
- "audit_table.successfullPassword": "Successfully changed password",
- "audit_table.failedPassword": "Failed to change password - tried to update user password who was logged in through oauth",
- "audit_table.updatedRol": "Updated user role(s) to ",
- "audit_table.member": "member",
- "audit_table.accountActive": "Account made active",
- "audit_table.accountInactive": "Account made inactive",
- "audit_table.by": " by {username}",
- "audit_table.byAdmin": " by an admin",
- "audit_table.sentEmail": "Sent an email to {email} to reset your password",
- "audit_table.attemptedReset": "Attempted to reset password",
- "audit_table.successfullReset": "Successfully reset password",
- "audit_table.updateGlobalNotifications": "Updated your global notification settings",
- "audit_table.attemptedWebhookCreate": "Attempted to create a webhook",
- "audit_table.successfullWebhookCreate": "Successfully created a webhook",
- "audit_table.failedWebhookCreate": "Failed to create a webhook - bad channel permissions",
- "audit_table.attemptedWebhookDelete": "Attempted to delete a webhook",
- "audit_table.successfullWebhookDelete": "Successfully deleted a webhook",
- "audit_table.failedWebhookDelete": "Failed to delete a webhook - inappropriate conditions",
- "audit_table.logout": "Logged out of your account",
- "audit_table.verified": "Sucessfully verified your email address",
- "audit_table.revokedAll": "Revoked all current sessions for the team",
- "audit_table.loginAttempt": " (Login attempt)",
- "audit_table.loginFailure": " (Login failure)",
- "audit_table.userId": "User ID",
- "audit_table.moreInfo": "More info",
- "audit_table.ip": "IP: {ip}",
- "audit_table.session": "Session ID: {id}",
"authorize.title": "An application would like to connect to your {teamName} account",
"authorize.app": "The app <strong>{appName}</strong> would like the ability to access and modify your basic information.",
"authorize.access": "Allow <strong>{appName}</strong> access?",
@@ -566,7 +567,6 @@
"create_post.comment": "Comment",
"create_post.post": "Post",
"create_post.write": "Write a message...",
- "create_post.deleteMsg": "(message deleted)",
"create_post.tutorialTip": "<h4>Sending Messages</h4><p>Type here to write a message and press <strong>Enter</strong> to post it.</p><p>Click the <strong>Attachment</strong> button to upload an image or a file.</p>",
"delete_channel.channel": "channel",
"delete_channel.group": "group",
@@ -772,6 +772,7 @@
"members_popover.title": "Members",
"post_attachment.collapse": "▲ collapse text",
"post_attachment.more": "▼ read more",
+ "post_body.deleted": "(message deleted)",
"post_body.plusOne": " plus 1 other file",
"post_body.plusMore": " plus {count} other files",
"post_body.commentedOn": "Commented on {name}{apostrophe} message: ",
@@ -1057,6 +1058,41 @@
"user.settings.import_theme.importBody": "To import a theme, go to a Slack team and look for “Preferences -> Sidebar Theme”. Open the custom theme option, copy the theme color values and paste them here:",
"user.settings.import_theme.cancel": "Cancel",
"user.settings.import_theme.submit": "Submit",
+ "user.settings.cmds.request_type_post": "POST",
+ "user.settings.cmds.request_type_get": "GET",
+ "user.settings.cmds.add_display_name.placeholder": "Display Name",
+ "user.settings.cmds.add_username.placeholder": "Username",
+ "user.settings.cmds.add_trigger.placeholder": "Command trigger e.g. \"hello\" not including the slash",
+ "user.settings.cmds.auto_complete_desc.placeholder": "A short description of what this commands does.",
+ "user.settings.cmds.auto_complete_hint.placeholder": "[zipcode]",
+ "user.settings.cmds.url.placeholder": "Must start with http:// or https://",
+ "user.settings.cmds.auto_complete.yes": "yes",
+ "user.settings.cmds.auto_complete.no": "no",
+ "user.settings.cmds.trigger": "Trigger: ",
+ "user.settings.cmds.display_name": "Display Name: ",
+ "user.settings.cmds.username": "Username: ",
+ "user.settings.cmds.icon_url": "Icon URL: ",
+ "user.settings.cmds.auto_complete": "Auto Complete: ",
+ "user.settings.cmds.auto_complete_desc": "Auto Complete Description: ",
+ "user.settings.cmds.auto_complete_hint": "Auto Complete Hint: ",
+ "user.settings.cmds.request_type": "Request Type: ",
+ "user.settings.cmds.url": "URL: ",
+ "user.settings.cmds.token": "Token: ",
+ "user.settings.cmds.regen": "Regen Token",
+ "user.settings.cmds.none": "None",
+ "user.settings.cmds.existing": "Existing commands",
+ "user.settings.cmds.add_desc": "Create commands to send message events to an external integration. Please see <a href=\"http://mattermost.org/commands\">http://mattermost.org/commands</a> to learn more.",
+ "user.settings.cmds.add_new": "Add a new command",
+ "user.settings.cmds.cmd_display_name": "Command display name.",
+ "user.settings.cmds.username_desc": "The username to use when overriding the post.",
+ "user.settings.cmds.icon_url_desc": "URL to an icon",
+ "user.settings.cmds.trigger_desc": "Word to trigger on",
+ "user.settings.cmds.auto_complete_desc_desc": "A short description of what this commands does",
+ "user.settings.cmds.auto_complete_help": "Show this command in autocomplete list.",
+ "user.settings.cmds.auto_complete_hint_desc": "List parameters to be passed to the command.",
+ "user.settings.cmds.request_type_desc": "Command request type issued to the callback URL.",
+ "user.settings.cmds.url_desc": "URL that will receive the HTTP POST or GET event",
+ "user.settings.cmds.add": "Add",
"user.settings.hooks_in.channel": "Channel: ",
"user.settings.hooks_in.none": "None",
"user.settings.hooks_in.existing": "Existing incoming webhooks",
@@ -1155,37 +1191,6 @@
"user.settings.integrations.commands": "Commands",
"user.settings.integrations.commandsDescription": "Manage your commands",
"user.settings.integrations.title": "Integration Settings",
- "user.settings.cmds.trigger": "Trigger: ",
- "user.settings.cmds.display_name": "Display Name: ",
- "user.settings.cmds.username": "Username: ",
- "user.settings.cmds.icon_url": "Icon URL: ",
- "user.settings.cmds.auto_complete": "Auto Complete: ",
- "user.settings.cmds.auto_complete_desc": "Auto Complete Description: ",
- "user.settings.cmds.auto_complete_hint": "Auto Complete Hint: ",
- "user.settings.cmds.request_type": "Request Type: ",
- "user.settings.cmds.request_type_post": "POST",
- "user.settings.cmds.request_type_get": "GET",
- "user.settings.cmds.url": "URL: ",
- "user.settings.cmds.token": "Token: ",
- "user.settings.cmds.regen": "Regen Token",
- "user.settings.cmds.none": "None",
- "Existing commands": "Existing commands",
- "user.settings.cmds.add_desc": "Create commands to send message events to an external integration. Please see <a href=\"http://mattermost.org/commands\">http://mattermost.org/commands</a> to learn more.",
- "user.settings.cmds.add_display_name.placeholder": "Display Name",
- "user.settings.cmds.existing": "Existing commands",
- "user.settings.cmds.add_username.placeholder": "Username",
- "user.settings.cmds.username_desc": "The username to use when overriding the post.",
- "user.settings.cmds.icon_url_desc": "URL to an icon",
- "user.settings.cmds.trigger_desc": "Word to trigger on",
- "user.settings.cmds.add_trigger.placeholder": "Command trigger e.g. \"hello\" not including the slash",
- "user.settings.cmds.auto_complete_desc_desc": "A short description of what this commands does",
- "user.settings.cmds.auto_complete_help": "Show this command in autocomplete list.",
- "user.settings.cmds.auto_complete_desc.placeholder": "A short description of what this commands does.",
- "user.settings.cmds.auto_complete_hint.placeholder": "[zipcode]",
- "user.settings.cmds.auto_complete_hint_desc": "List parameters to be passed to the command.",
- "user.settings.cmds.request_type_desc": "Command request type issued to the callback URL.",
- "user.settings.cmds.url_desc": "URL that will receive the HTTP POST or GET event",
- "user.settings.cmds.url.placeholder": "Must start with http:// or https://",
"user.settings.modal.general": "General",
"user.settings.modal.security": "Security",
"user.settings.modal.notifications": "Notifications",
@@ -1206,9 +1211,10 @@
"user.settings.notification.allActivity": "For all activity",
"user.settings.notifications.onlyMentions": "Only for mentions and direct messages",
"user.settings.notifications.never": "Never",
- "user.settings.notifications.info": "Desktop notification sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.",
+ "user.settings.notifications.info": "Desktop notifications are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.",
"user.settings.notifications.on": "On",
"user.settings.notifications.off": "Off",
+ "user.settings.notifications.sounds_info": "Desktop notifications sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.",
"user.settings.notification.soundConfig": "Please configure notification sounds in your browser settings",
"user.settings.notifications.emailInfo": "Email notifications are sent for mentions and direct messages after you’ve been offline for more than 60 seconds or away from {siteName} for more than 5 minutes.",
"user.settings.notifications.sensitiveName": "Your case sensitive first name \"{first_name}\"",
diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json
index a918b845f..cae6a0ffd 100644
--- a/web/static/i18n/es.json
+++ b/web/static/i18n/es.json
@@ -211,7 +211,7 @@
"admin.licence.keyMigration": "Si estás migrando servidores es posible que necesites remover tu licencia de este servidor para poder instalarlo en un servidor nuevo. Para empezar,\n <a href=\"http://mattermost.com\" target=\"_blank\">deshabilita todas las características de la Edición Enterprise de este servidor</a>.\n Esta operación habilitará la opción para remover la licencia y degradar este servidor de la Edición Enterprise a la Edición Team.",
"admin.license.edition": "Edición: ",
"admin.license.enterpriseEdition": "Mattermost Edición Enterprise. Diseñada para comunicación de escala empresarial.",
- "admin.license.entrepriseType": "<div><p>Esta versión compilada de la plataforma de Mattermost es proporcionada bajo una <a href=\"http://mattermost.com\" target=\"_blank\">licencia comercial</a>\n de Mattermost, Inc. basado en tu nivel de subscripción y sujeto a los <a href=\"{terms}\" target=\"_blank\">Términos del Servicio.</a></p>\n <p>Los detalles de tu subscripción son los siguientes:</p>\n Nombre: {name}<br />\n Nombre de compañia u organización: {company}<br/>\n Cantidad de usuarios: {users}<br/>\n Licencia emitida por: {issued}<br/>\n Inicio de la licencia: {start}<br/>\n Fecha de expiración: {expires}<br/>\n LDAP: {ldap}<br/></div>",
+ "admin.license.enterpriseType": "<div><p>Esta versión compilada de la plataforma de Mattermost es proporcionada bajo una <a href=\"http://mattermost.com\" target=\"_blank\">licencia comercial</a> de Mattermost, Inc. basado en tu nivel de subscripción y sujeto a los <a href=\"{terms}\" target=\"_blank\">Términos del Servicio.</a></p><p>Los detalles de tu subscripción son los siguientes:</p>Nombre: {name}<br />Nombre de compañia u organización: {company}<br/>Cantidad de usuarios: {users}<br/>Licencia emitida por: {issued}<br/>Inicio de la licencia: {start}<br/>Fecha de expiración: {expires}<br/>LDAP: {ldap}<br/></div>",
"admin.license.key": "Llave de la Licencia: ",
"admin.license.keyRemove": "Remover la Licencia Enterprise y Degradar el Servidor",
"admin.license.removing": "Removiendo Licencia...",
@@ -293,12 +293,18 @@
"admin.service.attemptDescription": "Inicio de sesión permitidos antes que el usuario sea bloqueado y se requiera volver a configurar la contraseña vía correo electrónico.",
"admin.service.attemptExample": "Ej \"10\"",
"admin.service.attemptTitle": "Máximo de intentos de conexión:",
+ "admin.service.cmdsDesc": "Cuando es verdadero, se permite la creación de comandos de barra por usuarios.",
+ "admin.service.cmdsTitle": "Habilitar Comandos de Barra: ",
"admin.service.developerDesc": "(Opción de Desarrollador) Cuando está asignado en verdadero, información extra sobre errores se muestra en el UI.",
"admin.service.developerTitle": "Habilitar modo de Desarrollador: ",
"admin.service.false": "falso",
"admin.service.googleDescription": "Asigna una llave a este campo para habilitar la previsualización de videos de YouTube tomados de los enlaces que aparecen en los mensajes o comentarios. Las instrucciones de como obtener una llave está disponible en <a href=\"https://www.youtube.com/watch?v=Im69kzhpR3I\" target=\"_blank\">https://www.youtube.com/watch?v=Im69kzhpR3I</a>. Al dejar este campo en blanco deshabilita la generación de previsualizaciones de videos de YouTube desde los enlaces.",
"admin.service.googleExample": "Ej \"7rAh6iwQCkV4cA1Gsg3fgGOXJAQ43QV\"",
"admin.service.googleTitle": "Llave de desarrolador Google:",
+ "admin.service.iconDescription": "Cuando es verdadero, se le permitirá cambiar el icono del mensaje desde webhooks. Nota, en combinación con permitir el cambio de nombre de usuario, podría exponer a los usuarios a sufrir ataques de phishing.",
+ "admin.service.iconTitle": "Habilitar el cambio de icono desde los Webhooks: ",
+ "admin.service.integrationAdmin": "Habilitar Integraciones sólo para administradores: ",
+ "admin.service.integrationAdminDesc": "Cuando es verdadero, las integraciones creadas por usuarios solo pueden ser creadas por administradores.",
"admin.service.listenAddress": "Dirección de escucha:",
"admin.service.listenDescription": "La dirección a la que se unirá y escuchará. Ingresar \":8065\" se podrá unir a todas las interfaces o podrá seleccionar una como ej: \"127.0.0.1:8065\". Cambiando este valor es necesario reiniciar el servidor.",
"admin.service.listenExample": "Ej \":8065\"",
@@ -306,6 +312,8 @@
"admin.service.mobileSessionDaysDesc": "La sesión nativa de los dispositivos moviles expirará luego de transcurrido el numero de días especificado y se solicitará al usuario que inicie sesión nuevamente.",
"admin.service.outWebhooksDesc": "Cuando es verdadero, los webhooks de salida serán permitidos.",
"admin.service.outWebhooksTitle": "Habilitar Webhooks de Salida: ",
+ "admin.service.overrideDescription": "Cuando es verdadero, se le permitirá cambiar el nombre de usuario desde webhooks. Nota, en conjunto con cambio de icono, podría exponer a los usuarios a sufrir ataques de phishing.",
+ "admin.service.overrideTitle": "Habilitar el cambio de nombres de usuario desde los Webhooks: ",
"admin.service.save": "Guardar",
"admin.service.saving": "Guardando....",
"admin.service.securityDesc": "Cuando es verdadero, Los Administradores del Sistema serán notificados por correo electrónico se han anunciado alertas de seguridad relevantes en las últimas 12 horas. Requiere que los correos estén habilitados.",
@@ -585,7 +593,6 @@
"create_comment.file": "Subiendo archivo",
"create_comment.files": "Subiendo archivos",
"create_post.comment": "Comentario",
- "create_post.deleteMsg": "(mensaje eliminado)",
"create_post.post": "Mensaje",
"create_post.tutorialTip": "<h4>Enviar Mensajes</h4> <p>Escribe aquí para redactar un mensaje y presiona <strong>Retorno</strong> para enviarlo.</p><p>Pincha el botón de <strong>Adjuntar</strong> para subir una imagen o archivo.</p>",
"create_post.write": "Escribe un mensaje...",
@@ -805,6 +812,7 @@
"post_attachment.collapse": "▲ colapsar texto",
"post_attachment.more": "▼ leer más",
"post_body.commentedOn": "Comentó el mensaje de {name}{apostrophe}: ",
+ "post_body.deleted": "(mensaje eliminado)",
"post_body.plusMore": " más {count} otros archivos",
"post_body.plusOne": " más 1 archivo",
"post_body.retry": "Reintentar",
@@ -894,8 +902,8 @@
"sidebar.tutorialScreen1": "<h4>Canales</h4><p><strong>Canales</strong> organizan las conversaciones en diferentes tópicos. Son abiertos para cualquier persona de tu equipo. Para enviar comunicaciones privadas con una sola persona utiliza <strong>Mensajes Directos</strong> o con multiples personas utilizando <strong>Grupos Privados</strong>.</p>",
"sidebar.tutorialScreen2": "<h4>Canal \"General\"</h4><p>Este es un canal para comenzar:</p><p><strong>General</strong> es el lugar para tener comunicación con todo el equipo. Todos los integrantes de tu equipo son miembros de este canal.</p>",
"sidebar.tutorialScreen3": "<h4>Creando y Uniendose a Canales</h4><p>Pincha en <strong>\"Más...\"</strong> para crear un nuevo canal o unirte a uno existente.</p><p>También puedes crear un nuevo canal o grupo privado al pinchar el simbolo de <strong>\"+\"</strong> que se encuentra al lado del encabezado de Canales o Grupos Privados.</p>",
- "sidebar.unreadAbove": "Mensaje(s) sin leer arriba",
- "sidebar.unreadBelow": "Mensaje(s) sin leer abajo",
+ "sidebar.unreadAbove": "Mensaje(s) sin leer ▲",
+ "sidebar.unreadBelow": "Mensaje(s) sin leer ▼",
"sidebar_header.tutorial": "<h4>Menú Principal</h4><p>El <strong>Menú Principal</strong> es donde puedes <strong>Invitar a nuevos miembros</strong>, podrás <strong>Configurar tu Cuenta</strong> y seleccionar un <strong>Tema</strong> para personalizar la apariencia.</p><p>Los administradores del Equipo podrán <strong>Configurar el Equipo</strong> desde este menú.</p><p>Los administradores del Sistema encontrarán una opción para ir a la <strong>Consola de Sistema</strong> para administrar el sistema completo.</p>",
"sidebar_right_menu.accountSettings": "Configurar tu Cuenta",
"sidebar_right_menu.console": "Consola del Sistema",
@@ -1054,6 +1062,41 @@
"user.settings.appearance.save": "Guardar",
"user.settings.appearance.themeColors": "Selecciona un Tema",
"user.settings.appearance.title": "Configuraciones de Apariencia",
+ "user.settings.cmds.add": "Agregar",
+ "user.settings.cmds.add_desc": "Crea comandos que permitan enviar eventos a integraciones externas. Por favor revisa <a href=\"http://mattermost.org/commands\">http://mattermost.org/commands</a> para aprender más.",
+ "user.settings.cmds.add_display_name.placeholder": "Nombre a mostrar",
+ "user.settings.cmds.add_new": "Agregar un nuevo comando",
+ "user.settings.cmds.add_trigger.placeholder": "Gatillador del Comando ej. \"hola\" no se debe incluir la barra",
+ "user.settings.cmds.add_username.placeholder": "Nombre de usuario",
+ "user.settings.cmds.auto_complete": "Auto completado: ",
+ "user.settings.cmds.auto_complete.no": "no",
+ "user.settings.cmds.auto_complete.yes": "sí",
+ "user.settings.cmds.auto_complete_desc": "Descripción del Auto Completado: ",
+ "user.settings.cmds.auto_complete_desc.placeholder": "Una pequeña descripción de que hace el comando.",
+ "user.settings.cmds.auto_complete_desc_desc": "Una pequeña descripción de que hace el comando",
+ "user.settings.cmds.auto_complete_help": "Mostrar este comando en la lista de auto completado.",
+ "user.settings.cmds.auto_complete_hint": "Pista de auto completado: ",
+ "user.settings.cmds.auto_complete_hint.placeholder": "[código postal]",
+ "user.settings.cmds.auto_complete_hint_desc": "Lista de parámetros que recibe el comando.",
+ "user.settings.cmds.cmd_display_name": "Nombre a mostrar del Comando.",
+ "user.settings.cmds.display_name": "Nombre a mostrar: ",
+ "user.settings.cmds.existing": "Comandos existentes",
+ "user.settings.cmds.icon_url": "URL del icono: ",
+ "user.settings.cmds.icon_url_desc": "URL para un icono",
+ "user.settings.cmds.none": "Ninguno",
+ "user.settings.cmds.regen": "Regenerar Token",
+ "user.settings.cmds.request_type": "Tipo de Solicitud: ",
+ "user.settings.cmds.request_type_desc": "Tipo de solicitud emitido al callback URL por el Comando.",
+ "user.settings.cmds.request_type_get": "GET",
+ "user.settings.cmds.request_type_post": "POST",
+ "user.settings.cmds.token": "Token: ",
+ "user.settings.cmds.trigger": "Gatillador: ",
+ "user.settings.cmds.trigger_desc": "Palabra que gatilla la acción",
+ "user.settings.cmds.url": "URL: ",
+ "user.settings.cmds.url.placeholder": "Debe comenzar con http:// o https://",
+ "user.settings.cmds.url_desc": "URL que va a recibir el evento HTTP POST o GET",
+ "user.settings.cmds.username": "Nombre de usuario: ",
+ "user.settings.cmds.username_desc": "El nombre de usuario a utilizar cuando se genere el mensaje.",
"user.settings.custom_theme.awayIndicator": "Indicador Ausente",
"user.settings.custom_theme.buttonBg": "Fondo Botón",
"user.settings.custom_theme.buttonColor": "Texto Botón",
@@ -1151,6 +1194,8 @@
"user.settings.import_theme.importHeader": "Importar Tema de Slack",
"user.settings.import_theme.submit": "Enviar",
"user.settings.import_theme.submitError": "Formato inválido, por favor intenta copiando y pegando nuevamente.",
+ "user.settings.integrations.commands": "Comandos",
+ "user.settings.integrations.commandsDescription": "Administra tus comandos",
"user.settings.integrations.incomingWebhooks": "Webhooks de entrada",
"user.settings.integrations.incomingWebhooksDescription": "Administra tus webhooks de entrada",
"user.settings.integrations.outWebhooks": "Webhooks de salida",
@@ -1188,6 +1233,7 @@
"user.settings.notifications.sensitiveName": "Tu nombre con distinción de mayúsculas \"{first_name}\"",
"user.settings.notifications.sensitiveUsername": "Tu nombre de usuario sin distinción de mayúsculas \"{username}\"",
"user.settings.notifications.sensitiveWords": "Otras palabras sin distinción de mayúsculas, separadas por comas:",
+ "user.settings.notifications.sounds_info": "Las notificaciones de sonido de Escritorio están disponibles en Firefox, Safari, Chrome, Internet Explorer, y Edge.",
"user.settings.notifications.teamWide": "Menciones para todo el equipo \"@all\"",
"user.settings.notifications.title": "Configuracón de Notificaciones",
"user.settings.notifications.usernameMention": "Tu nombre de usuario mencionado \"@{username}\"",