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/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.json109
-rw-r--r--web/static/i18n/es.json6
39 files changed, 1317 insertions, 848 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/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 aee193c4b..9338cc766 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: ",
@@ -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: ",
@@ -1262,4 +1263,4 @@
"intro_messages.beginning": "Beginning of {name}",
"intro_messages.invite": "Invite others to this {type}",
"intro_messages.setHeader": "Set a Header"
-} \ No newline at end of file
+}
diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json
index 962fe176a..5cd36930c 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...",
@@ -593,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...",
@@ -813,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",
@@ -1262,4 +1262,4 @@
"view_image_popover.download": "Descargar",
"view_image_popover.file": "Archivo {count} de {total}",
"view_image_popover.publicLink": "Obtener Enlace Público"
-} \ No newline at end of file
+}