diff --git a/api/export.go b/api/export.go new file mode 100644 index 0000000000..9345f892fb --- /dev/null +++ b/api/export.go @@ -0,0 +1,292 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "archive/zip" + "encoding/json" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + "io" + "os" +) + +const ( + EXPORT_PATH = "export/" + EXPORT_FILENAME = "MattermostExport.zip" + EXPORT_OPTIONS_FILE = "options.json" + EXPORT_TEAMS_FOLDER = "teams" + EXPORT_CHANNELS_FOLDER = "channels" + EXPORT_CHANNEL_MEMBERS_FOLDER = "members" + EXPORT_POSTS_FOLDER = "posts" + EXPORT_USERS_FOLDER = "users" + EXPORT_LOCAL_STORAGE_FOLDER = "files" +) + +type ExportWriter interface { + Create(name string) (io.Writer, error) +} + +type ExportOptions struct { + TeamsToExport []string `json:"teams"` + ChannelsToExport []string `json:"channels"` + UsersToExport []string `json:"users"` + ExportLocalStorage bool `json:"export_local_storage"` +} + +func (options *ExportOptions) ToJson() string { + b, err := json.Marshal(options) + if err != nil { + return "" + } else { + return string(b) + } +} + +func ExportOptionsFromJson(data io.Reader) *ExportOptions { + decoder := json.NewDecoder(data) + var o ExportOptions + decoder.Decode(&o) + return &o +} + +func ExportToFile(options *ExportOptions) (link string, err *model.AppError) { + // Open file for export + if file, err := openFileWriteStream(EXPORT_PATH + EXPORT_FILENAME); err != nil { + return "", err + } else { + defer closeFileWriteStream(file) + ExportToWriter(file, options) + } + + return "/api/v1/files/get_export", nil +} + +func ExportToWriter(w io.Writer, options *ExportOptions) *model.AppError { + // Open a writer to write to zip file + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + // Write our options to file + if optionsFile, err := zipWriter.Create(EXPORT_OPTIONS_FILE); err != nil { + return model.NewAppError("ExportToWriter", "Unable to create options file", err.Error()) + } else { + if _, err := optionsFile.Write([]byte(options.ToJson())); err != nil { + return model.NewAppError("ExportToWriter", "Unable to write to options file", err.Error()) + } + } + + // Export Teams + ExportTeams(zipWriter, options) + + return nil +} + +func ExportTeams(writer ExportWriter, options *ExportOptions) *model.AppError { + // Get the teams + var teams []*model.Team + if len(options.TeamsToExport) == 0 { + if result := <-Srv.Store.Team().GetForExport(); result.Err != nil { + return result.Err + } else { + teams = result.Data.([]*model.Team) + } + } else { + for _, teamId := range options.TeamsToExport { + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + return result.Err + } else { + team := result.Data.(*model.Team) + teams = append(teams, team) + } + } + } + + // Export the teams + for i := range teams { + // Sanitize + teams[i].PreExport() + + if teamFile, err := writer.Create(EXPORT_TEAMS_FOLDER + "/" + teams[i].Name + ".json"); err != nil { + return model.NewAppError("ExportTeams", "Unable to open file for export", err.Error()) + } else { + if _, err := teamFile.Write([]byte(teams[i].ToJson())); err != nil { + return model.NewAppError("ExportTeams", "Unable to write to team export file", err.Error()) + } + } + + } + + // Export the channels, local storage and users + for _, team := range teams { + if err := ExportChannels(writer, options, team.Id); err != nil { + return err + } + if err := ExportUsers(writer, options, team.Id); err != nil { + return err + } + if err := ExportLocalStorage(writer, options, team.Id); err != nil { + return err + } + } + + return nil +} + +func ExportChannels(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError { + // Get the channels + var channels []*model.Channel + if len(options.ChannelsToExport) == 0 { + if result := <-Srv.Store.Channel().GetForExport(teamId); result.Err != nil { + return result.Err + } else { + channels = result.Data.([]*model.Channel) + } + } else { + for _, channelId := range options.ChannelsToExport { + if result := <-Srv.Store.Channel().Get(channelId); result.Err != nil { + return result.Err + } else { + channel := result.Data.(*model.Channel) + channels = append(channels, channel) + } + } + } + + for i := range channels { + // Get members + mchan := Srv.Store.Channel().GetMembers(channels[i].Id) + + // Sanitize + channels[i].PreExport() + + if channelFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + ".json"); err != nil { + return model.NewAppError("ExportChannels", "Unable to open file for export", err.Error()) + } else { + if _, err := channelFile.Write([]byte(channels[i].ToJson())); err != nil { + return model.NewAppError("ExportChannels", "Unable to write to export file", err.Error()) + } + } + + var members []model.ChannelMember + if result := <-mchan; result.Err != nil { + return result.Err + } else { + members = result.Data.([]model.ChannelMember) + } + + if membersFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + "_members.json"); err != nil { + return model.NewAppError("ExportChannels", "Unable to open file for export", err.Error()) + } else { + result, err2 := json.Marshal(members) + if err2 != nil { + return model.NewAppError("ExportChannels", "Unable to convert to json", err.Error()) + } + if _, err3 := membersFile.Write([]byte(result)); err3 != nil { + return model.NewAppError("ExportChannels", "Unable to write to export file", err.Error()) + } + } + } + + for _, channel := range channels { + if err := ExportPosts(writer, options, channel.Id); err != nil { + return err + } + } + + return nil +} + +func ExportPosts(writer ExportWriter, options *ExportOptions, channelId string) *model.AppError { + // Get the posts + var posts []*model.Post + if result := <-Srv.Store.Post().GetForExport(channelId); result.Err != nil { + return result.Err + } else { + posts = result.Data.([]*model.Post) + } + + // Export the posts + if postsFile, err := writer.Create(EXPORT_POSTS_FOLDER + "/" + channelId + "_posts.json"); err != nil { + return model.NewAppError("ExportPosts", "Unable to open file for export", err.Error()) + } else { + result, err2 := json.Marshal(posts) + if err2 != nil { + return model.NewAppError("ExportPosts", "Unable to convert to json", err.Error()) + } + if _, err3 := postsFile.Write([]byte(result)); err3 != nil { + return model.NewAppError("ExportPosts", "Unable to write to export file", err.Error()) + } + } + + return nil +} + +func ExportUsers(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError { + // Get the users + var users []*model.User + if result := <-Srv.Store.User().GetForExport(teamId); result.Err != nil { + return result.Err + } else { + users = result.Data.([]*model.User) + } + + // Write the users + if usersFile, err := writer.Create(EXPORT_USERS_FOLDER + "/" + teamId + "_users.json"); err != nil { + return model.NewAppError("ExportUsers", "Unable to open file for export", err.Error()) + } else { + result, err2 := json.Marshal(users) + if err2 != nil { + return model.NewAppError("ExportUsers", "Unable to convert to json", err.Error()) + } + if _, err3 := usersFile.Write([]byte(result)); err3 != nil { + return model.NewAppError("ExportUsers", "Unable to write to export file", err.Error()) + } + } + return nil +} + +func copyDirToExportWriter(writer ExportWriter, inPath string, outPath string) *model.AppError { + dir, err := os.Open(inPath) + if err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to open directory", err.Error()) + } + + fileInfoList, err := dir.Readdir(0) + if err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to read directory", err.Error()) + } + + for _, fileInfo := range fileInfoList { + if fileInfo.IsDir() { + copyDirToExportWriter(writer, inPath+"/"+fileInfo.Name(), outPath+"/"+fileInfo.Name()) + } else { + if toFile, err := writer.Create(outPath + "/" + fileInfo.Name()); err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to open file for export", err.Error()) + } else { + fromFile, err := os.Open(inPath + "/" + fileInfo.Name()) + if err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to open file", err.Error()) + } + io.Copy(toFile, fromFile) + } + } + } + + return nil +} + +func ExportLocalStorage(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError { + teamDir := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + teamId + + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + return model.NewAppError("ExportLocalStorage", "S3 is not supported for local storage export.", "") + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if err := copyDirToExportWriter(writer, teamDir, EXPORT_LOCAL_STORAGE_FOLDER); err != nil { + return err + } + } + + return nil +} diff --git a/api/file.go b/api/file.go index 800c512c5b..1d8244fac4 100644 --- a/api/file.go +++ b/api/file.go @@ -40,6 +40,7 @@ func InitFile(r *mux.Router) { sr.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFile)).Methods("GET") sr.Handle("/get_info/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFileInfo)).Methods("GET") sr.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST") + sr.Handle("/get_export", ApiUserRequired(getExport)).Methods("GET") } func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { @@ -414,6 +415,23 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(rData))) } +func getExport(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasPermissionsToTeam(c.Session.TeamId, "export") || !c.IsTeamAdmin(c.Session.UserId) { + c.Err = model.NewAppError("getExport", "Only a team admin can retrieve exported data.", "userId="+c.Session.UserId) + c.Err.StatusCode = http.StatusForbidden + return + } + data, err := readFile(EXPORT_PATH + EXPORT_FILENAME) + if err != nil { + c.Err = model.NewAppError("getExport", "Unable to retrieve exported file. Please re-export", err.Error()) + return + } + + w.Header().Set("Content-Disposition", "attachment; filename="+EXPORT_FILENAME) + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(data) +} + func writeFile(f []byte, path string) *model.AppError { if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { @@ -488,3 +506,27 @@ func readFile(path string) ([]byte, *model.AppError) { return nil, model.NewAppError("readFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") } } + +func openFileWriteStream(path string) (io.Writer, *model.AppError) { + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + return nil, model.NewAppError("openFileWriteStream", "S3 is not supported.", "") + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if err := os.MkdirAll(filepath.Dir(utils.Cfg.ServiceSettings.StorageDirectory+path), 0774); err != nil { + return nil, model.NewAppError("openFileWriteStream", "Encountered an error creating the directory for the new file", err.Error()) + } + + if fileHandle, err := os.Create(utils.Cfg.ServiceSettings.StorageDirectory + path); err != nil { + return nil, model.NewAppError("openFileWriteStream", "Encountered an error writing to local server storage", err.Error()) + } else { + fileHandle.Chmod(0644) + return fileHandle, nil + } + + } + + return nil, model.NewAppError("openFileWriteStream", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") +} + +func closeFileWriteStream(file io.Writer) { + file.(*os.File).Close() +} diff --git a/api/team.go b/api/team.go index 8cce384c3c..e1b3b274af 100644 --- a/api/team.go +++ b/api/team.go @@ -32,7 +32,9 @@ func InitTeam(r *mux.Router) { sr.Handle("/update_name", ApiUserRequired(updateTeamDisplayName)).Methods("POST") sr.Handle("/update_valet_feature", ApiUserRequired(updateValetFeature)).Methods("POST") sr.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET") + // These should be moved to the global admain console sr.Handle("/import_team", ApiUserRequired(importTeam)).Methods("POST") + sr.Handle("/export_team", ApiUserRequired(exportTeam)).Methods("GET") } func signupTeam(c *Context, w http.ResponseWriter, r *http.Request) { @@ -675,3 +677,22 @@ func importTeam(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") http.ServeContent(w, r, "MattermostImportLog.txt", time.Now(), bytes.NewReader(log.Bytes())) } + +func exportTeam(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasPermissionsToTeam(c.Session.TeamId, "export") || !c.IsTeamAdmin(c.Session.UserId) { + c.Err = model.NewAppError("exportTeam", "Only a team admin can export data.", "userId="+c.Session.UserId) + c.Err.StatusCode = http.StatusForbidden + return + } + + options := ExportOptionsFromJson(r.Body) + + if link, err := ExportToFile(options); err != nil { + c.Err = err + return + } else { + result := map[string]string{} + result["link"] = link + w.Write([]byte(model.MapToJson(result))) + } +} diff --git a/doc/install/aws-ebs-setup.md b/doc/install/aws-ebs-setup.md index 452cfcb4fb..e186fa9c14 100644 --- a/doc/install/aws-ebs-setup.md +++ b/doc/install/aws-ebs-setup.md @@ -1,14 +1,14 @@ ## AWS Elastic Beanstalk Setup (Docker) -1. Create a new Elastic Beanstalk Docker application using the [Dockerrun.aws.zip](docker/0.6/Dockerrun.aws//Dockerrun.aws.zip) file provided. +1. Create a new Elastic Beanstalk Docker application using the [Dockerrun.aws.zip](/docker/0.7/Dockerrun.aws.zip) file provided. 1. From the AWS console select Elastic Beanstalk. 2. Select "Create New Application" from the top right. 3. Name the application and press next. 4. Select "Create a web server" environment. 5. If asked, select create an IAM role and instance profile and press next. 6. For predefined configuration select under Generic: Docker. For environment type select single instance. - 7. For application source, select upload your own and upload Dockerrun.aws.zip from [Dockerrun.aws.zip](docker/0.6/Dockerrun.aws//Dockerrun.aws.zip). Everything else may be left at default. + 7. For application source, select upload your own and upload Dockerrun.aws.zip from [Dockerrun.aws.zip](/docker/0.7/Dockerrun.aws.zip). Everything else may be left at default. 8. Select an environment name, this is how you will refer to your environment. Make sure the URL is available then press next. 9. The options on the additional resources page may be left at default unless you wish to change them. Press Next. 10. On the configuration details place. Select an instance type of t2.small or larger. diff --git a/model/channel.go b/model/channel.go index b46f79f75a..7d8edeee7d 100644 --- a/model/channel.go +++ b/model/channel.go @@ -117,3 +117,6 @@ func (o *Channel) PreUpdate() { func (o *Channel) ExtraUpdated() { o.ExtraUpdateAt = GetMillis() } + +func (o *Channel) PreExport() { +} diff --git a/model/post.go b/model/post.go index 0c035d4e79..e78469940e 100644 --- a/model/post.go +++ b/model/post.go @@ -147,3 +147,6 @@ func (o *Post) AddProp(key string, value string) { o.Props[key] = value } + +func (o *Post) PreExport() { +} diff --git a/model/team.go b/model/team.go index 95e2757c86..6006f738c6 100644 --- a/model/team.go +++ b/model/team.go @@ -197,3 +197,6 @@ func CleanTeamName(s string) string { return s } + +func (o *Team) PreExport() { +} diff --git a/model/user.go b/model/user.go index d82f96db34..9f90b82042 100644 --- a/model/user.go +++ b/model/user.go @@ -272,6 +272,16 @@ func (u *User) GetDisplayName() string { } } +func (u *User) PreExport() { + u.Password = "" + u.AuthData = "" + u.LastActivityAt = 0 + u.LastPingAt = 0 + u.LastPasswordUpdate = 0 + u.LastPictureUpdate = 0 + u.FailedAttempts = 0 +} + // UserFromJson will decode the input and return a User func UserFromJson(data io.Reader) *User { decoder := json.NewDecoder(data) diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index d2e3943dfa..b58166fd6b 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -678,3 +678,25 @@ func (s SqlChannelStore) UpdateNotifyLevel(channelId, userId, notifyLevel string return storeChannel } + +func (s SqlChannelStore) GetForExport(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var data []*model.Channel + _, err := s.GetReplica().Select(&data, "SELECT * FROM Channels WHERE TeamId = :TeamId AND DeleteAt = 0 AND Type = 'O'", map[string]interface{}{"TeamId": teamId}) + + if err != nil { + result.Err = model.NewAppError("SqlChannelStore.GetAllChannels", "We couldn't get all the channels", "teamId="+teamId+", err="+err.Error()) + } else { + result.Data = data + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 297d603976..20de23eb73 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -506,3 +506,27 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht return storeChannel } + +func (s SqlPostStore) GetForExport(channelId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var posts []*model.Post + _, err := s.GetReplica().Select( + &posts, + "SELECT * FROM Posts WHERE ChannelId = :ChannelId AND DeleteAt = 0", + map[string]interface{}{"ChannelId": channelId}) + if err != nil { + result.Err = model.NewAppError("SqlPostStore.GetForExport", "We couldn't get the posts for the channel", "channelId="+channelId+err.Error()) + } else { + result.Data = posts + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 2784f86303..fcbcaab9f2 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -193,3 +193,23 @@ func (s SqlTeamStore) GetTeamsForEmail(email string) StoreChannel { return storeChannel } + +func (s SqlTeamStore) GetForExport() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var data []*model.Team + if _, err := s.GetReplica().Select(&data, "SELECT * FROM Teams"); err != nil { + result.Err = model.NewAppError("SqlTeamStore.GetForExport", "We could not get all teams", err.Error()) + } + + result.Data = data + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 64a18545a3..be1d29df0a 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -452,3 +452,30 @@ func (us SqlUserStore) VerifyEmail(userId string) StoreChannel { return storeChannel } + +func (us SqlUserStore) GetForExport(teamId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*model.User + + if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewAppError("SqlUserStore.GetProfiles", "We encounted an error while finding user profiles", err.Error()) + } else { + for _, u := range users { + u.Password = "" + u.AuthData = "" + } + + result.Data = users + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/store.go b/store/store.go index 271caa3666..959e93fa4d 100644 --- a/store/store.go +++ b/store/store.go @@ -44,6 +44,7 @@ type TeamStore interface { Get(id string) StoreChannel GetByName(name string) StoreChannel GetTeamsForEmail(domain string) StoreChannel + GetForExport() StoreChannel } type ChannelStore interface { @@ -55,6 +56,7 @@ type ChannelStore interface { GetChannels(teamId string, userId string) StoreChannel GetMoreChannels(teamId string, userId string) StoreChannel GetChannelCounts(teamId string, userId string) StoreChannel + GetForExport(teamId string) StoreChannel SaveMember(member *model.ChannelMember) StoreChannel GetMembers(channelId string) StoreChannel @@ -78,6 +80,7 @@ type PostStore interface { GetPostsSince(channelId string, time int64) StoreChannel GetEtag(channelId string) StoreChannel Search(teamId string, userId string, terms string, isHashtagSearch bool) StoreChannel + GetForExport(channelId string) StoreChannel } type UserStore interface { @@ -96,6 +99,7 @@ type UserStore interface { VerifyEmail(userId string) StoreChannel GetEtagForProfiles(teamId string) StoreChannel UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel + GetForExport(teamId string) StoreChannel } type SessionStore interface { diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 871b72a434..50aa0850d1 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -55,6 +55,11 @@ export default class CreatePost extends React.Component { initialText: messageText }; } + componentDidUpdate(prevProps, prevState) { + if (prevState.previews.length !== this.state.previews.length) { + this.resizePostHolder(); + } + } handleSubmit(e) { e.preventDefault(); diff --git a/web/react/components/team_export_tab.jsx b/web/react/components/team_export_tab.jsx new file mode 100644 index 0000000000..1bc5abdb14 --- /dev/null +++ b/web/react/components/team_export_tab.jsx @@ -0,0 +1,96 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../utils/client.jsx'); + +export default class TeamExportTab extends React.Component { + constructor(props) { + super(props); + this.state = {status: 'request', link: '', err: ''}; + + this.onExportSuccess = this.onExportSuccess.bind(this); + this.onExportFailure = this.onExportFailure.bind(this); + this.doExport = this.doExport.bind(this); + } + onExportSuccess(data) { + this.setState({status: 'ready', link: data.link, err: ''}); + } + onExportFailure(e) { + this.setState({status: 'failure', link: '', err: e.message}); + } + doExport() { + if (this.state.status === 'in-progress') { + return; + } + this.setState({status: 'in-progress'}); + Client.exportTeam(this.onExportSuccess, this.onExportFailure); + } + render() { + var messageSection = ''; + switch (this.state.status) { + case 'request': + messageSection = ''; + break; + case 'in-progress': + messageSection = ( +

+ + {' Exporting...'} +

+ ); + break; + case 'ready': + messageSection = ( +

+ + {' Ready for '} + + {'download'} + +

+ ); + break; + case 'failure': + messageSection = ( +

+ + {' Unable to export: ' + this.state.err} +

+ ); + break; + } + + return ( +
+

{'Export'}

+
+ +
+ {messageSection} +
+ ); + } +} diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx index 53855fe1c8..396521af91 100644 --- a/web/react/components/team_settings.jsx +++ b/web/react/components/team_settings.jsx @@ -3,6 +3,7 @@ var TeamStore = require('../stores/team_store.jsx'); var ImportTab = require('./team_import_tab.jsx'); +var ExportTab = require('./team_export_tab.jsx'); var FeatureTab = require('./team_feature_tab.jsx'); var GeneralTab = require('./team_general_tab.jsx'); var Utils = require('../utils/utils.jsx'); @@ -64,6 +65,13 @@ export default class TeamSettings extends React.Component {
); break; + case 'export': + result = ( +
+ +
+ ); + break; default: result = (
diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx index 668bf76cf6..0513c811f3 100644 --- a/web/react/components/team_settings_modal.jsx +++ b/web/react/components/team_settings_modal.jsx @@ -36,6 +36,7 @@ export default class TeamSettingsModal extends React.Component { let tabs = []; tabs.push({name: 'general', uiName: 'General', icon: 'glyphicon glyphicon-cog'}); tabs.push({name: 'import', uiName: 'Import', icon: 'glyphicon glyphicon-upload'}); + tabs.push({name: 'export', uiName: 'Export', icon: 'glyphicon glyphicon-download'}); tabs.push({name: 'feature', uiName: 'Advanced', icon: 'glyphicon glyphicon-wrench'}); return ( diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx index 282fb76813..2a607b3e09 100644 --- a/web/react/components/user_settings.jsx +++ b/web/react/components/user_settings.jsx @@ -40,6 +40,7 @@ export default class UserSettings extends React.Component { user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} + updateTab={this.props.updateTab} />
); @@ -86,4 +87,4 @@ UserSettings.propTypes = { activeSection: React.PropTypes.string, updateSection: React.PropTypes.func, updateTab: React.PropTypes.func -}; \ No newline at end of file +}; diff --git a/web/react/components/user_settings_general.jsx b/web/react/components/user_settings_general.jsx index ead7ac1d55..f2127ce0ce 100644 --- a/web/react/components/user_settings_general.jsx +++ b/web/react/components/user_settings_general.jsx @@ -267,6 +267,28 @@ export default class UserSettingsGeneralTab extends React.Component {
); + function notifClick(e) { + e.preventDefault(); + this.updateSection(''); + this.props.updateTab('notifications'); + } + + const notifLink = ( + + Notifications + + ); + + const extraInfo = ( + + By default, you will receive mention notifications when someone types your first name. + Go to {notifLink} settings to change this default. + + ); + nameSection = ( ); } else { @@ -326,6 +349,13 @@ export default class UserSettingsGeneralTab extends React.Component { ); + const extraInfo = ( + + Use Nickname for a name you might be called that is different from your first name and user name. + This is most often used when two or more people have similar sounding names and usernames. + + ); + nicknameSection = ( ); } else { @@ -375,6 +406,8 @@ export default class UserSettingsGeneralTab extends React.Component { ); + const extraInfo = (Pick something easy for teammates to recognize and recall.); + usernameSection = ( ); } else { @@ -524,5 +558,6 @@ export default class UserSettingsGeneralTab extends React.Component { UserSettingsGeneralTab.propTypes = { user: React.PropTypes.object, updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, activeSection: React.PropTypes.string }; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 10f9c0b373..51fd164744 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -919,6 +919,19 @@ export function importSlack(fileData, success, error) { }); } +export function exportTeam(success, error) { + $.ajax({ + url: '/api/v1/teams/export_team', + type: 'GET', + dataType: 'json', + success: success, + error: function onError(xhr, status, err) { + var e = handleError('exportTeam', xhr, status, err); + error(e); + } + }); +} + export function getStatuses(success, error) { $.ajax({ url: '/api/v1/users/status',