summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.editorconfig8
-rw-r--r--Godeps/Godeps.json12
-rw-r--r--Godeps/_workspace/src/github.com/dgryski/dgoogauth/.travis.yml1
-rw-r--r--Godeps/_workspace/src/github.com/dgryski/dgoogauth/README.md15
-rw-r--r--Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth.go199
-rw-r--r--Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth_test.go251
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/gf256/Makefile8
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/gf256/blog_test.go85
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256.go241
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256_test.go194
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/Makefile4
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/Makefile7
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/gen.go149
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr.go815
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr_test.go133
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/Makefile4
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/qrencode.go149
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/png.go400
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/png_test.go73
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/qr.go116
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/pic.go506
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/play.go1118
-rw-r--r--Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/resize/resize.go152
-rw-r--r--Makefile15
-rw-r--r--README.md2
-rw-r--r--api/api.go2
-rw-r--r--api/context.go36
-rw-r--r--api/file.go5
-rw-r--r--api/oauth.go7
-rw-r--r--api/post.go2
-rw-r--r--api/user.go199
-rw-r--r--api/user_test.go76
-rw-r--r--config/config.json1
-rw-r--r--einterfaces/mfa.go25
-rw-r--r--i18n/en.json94
-rw-r--r--i18n/es.json72
-rw-r--r--i18n/fr.json14
-rw-r--r--i18n/pt.json84
-rw-r--r--mattermost.go2
-rw-r--r--model/client.go36
-rw-r--r--model/config.go6
-rw-r--r--model/license.go6
-rw-r--r--model/team.go4
-rw-r--r--model/user.go37
-rw-r--r--store/sql_user_store.go52
-rw-r--r--store/sql_user_store_test.go44
-rw-r--r--store/store.go2
-rw-r--r--templates/error.html24
-rw-r--r--utils/config.go1
-rw-r--r--utils/license.go1
-rw-r--r--webapp/Makefile5
-rw-r--r--webapp/action_creators/global_actions.jsx65
-rw-r--r--webapp/action_creators/websocket_actions.jsx21
-rw-r--r--webapp/components/about_build_modal.jsx133
-rw-r--r--webapp/components/admin_console/admin_navbar_dropdown.jsx2
-rw-r--r--webapp/components/admin_console/compliance_settings.jsx2
-rw-r--r--webapp/components/admin_console/service_settings.jsx58
-rw-r--r--webapp/components/admin_console/user_item.jsx2
-rw-r--r--webapp/components/analytics/team_analytics.jsx2
-rw-r--r--webapp/components/backstage/add_incoming_webhook.jsx198
-rw-r--r--webapp/components/backstage/add_integration.jsx76
-rw-r--r--webapp/components/backstage/add_integration_option.jsx39
-rw-r--r--webapp/components/backstage/add_outgoing_webhook.jsx270
-rw-r--r--webapp/components/backstage/backstage_category.jsx68
-rw-r--r--webapp/components/backstage/backstage_navbar.jsx61
-rw-r--r--webapp/components/backstage/backstage_section.jsx80
-rw-r--r--webapp/components/backstage/backstage_sidebar.jsx68
-rw-r--r--webapp/components/backstage/installed_incoming_webhook.jsx71
-rw-r--r--webapp/components/backstage/installed_integrations.jsx293
-rw-r--r--webapp/components/backstage/installed_outgoing_webhook.jsx91
-rw-r--r--webapp/components/center_panel.jsx145
-rw-r--r--webapp/components/channel_header.jsx17
-rw-r--r--webapp/components/channel_invite_button.jsx1
-rw-r--r--webapp/components/channel_notifications_modal.jsx2
-rw-r--r--webapp/components/channel_select.jsx79
-rw-r--r--webapp/components/channel_view.jsx58
-rw-r--r--webapp/components/claim/components/email_to_ldap.jsx14
-rw-r--r--webapp/components/error_page.jsx58
-rw-r--r--webapp/components/form_error.jsx50
-rw-r--r--webapp/components/invite_member_modal.jsx18
-rw-r--r--webapp/components/logged_in.jsx74
-rw-r--r--webapp/components/login/components/login_email.jsx (renamed from webapp/components/login_email.jsx)74
-rw-r--r--webapp/components/login/components/login_ldap.jsx (renamed from webapp/components/login_ldap.jsx)76
-rw-r--r--webapp/components/login/components/login_mfa.jsx92
-rw-r--r--webapp/components/login/components/login_username.jsx (renamed from webapp/components/login_username.jsx)91
-rw-r--r--webapp/components/login/login.jsx (renamed from webapp/components/login.jsx)371
-rw-r--r--webapp/components/more_channels.jsx4
-rw-r--r--webapp/components/more_direct_channels.jsx4
-rw-r--r--webapp/components/msg_typing.jsx12
-rw-r--r--webapp/components/navbar.jsx5
-rw-r--r--webapp/components/navbar_dropdown.jsx17
-rw-r--r--webapp/components/new_channel_flow.jsx7
-rw-r--r--webapp/components/permalink_view.jsx93
-rw-r--r--webapp/components/popover_list_members.jsx12
-rw-r--r--webapp/components/post.jsx14
-rw-r--r--webapp/components/post_body_additional_content.jsx2
-rw-r--r--webapp/components/post_info.jsx2
-rw-r--r--webapp/components/posts_view.jsx31
-rw-r--r--webapp/components/posts_view_container.jsx12
-rw-r--r--webapp/components/removed_from_channel_modal.jsx4
-rw-r--r--webapp/components/rename_channel_modal.jsx5
-rw-r--r--webapp/components/rhs_root_post.jsx2
-rw-r--r--webapp/components/root.jsx2
-rw-r--r--webapp/components/search_results_item.jsx22
-rw-r--r--webapp/components/sidebar.jsx102
-rw-r--r--webapp/components/sidebar_right.jsx4
-rw-r--r--webapp/components/signup_team.jsx55
-rw-r--r--webapp/components/signup_team_complete/components/signup_team_complete.jsx4
-rw-r--r--webapp/components/signup_user_complete.jsx89
-rw-r--r--webapp/components/spinner_button.jsx22
-rw-r--r--webapp/components/suggestion/search_suggestion_list.jsx1
-rw-r--r--webapp/components/team_general_tab.jsx1
-rw-r--r--webapp/components/team_settings_modal.jsx1
-rw-r--r--webapp/components/team_signup_with_sso.jsx2
-rw-r--r--webapp/components/textbox.jsx6
-rw-r--r--webapp/components/tutorial/tutorial_intro_screens.jsx22
-rw-r--r--webapp/components/tutorial/tutorial_tip.jsx9
-rw-r--r--webapp/components/tutorial/tutorial_view.jsx44
-rw-r--r--webapp/components/user_settings/manage_incoming_hooks.jsx225
-rw-r--r--webapp/components/user_settings/manage_outgoing_hooks.jsx397
-rw-r--r--webapp/components/user_settings/premade_theme_chooser.jsx13
-rw-r--r--webapp/components/user_settings/user_settings_advanced.jsx2
-rw-r--r--webapp/components/user_settings/user_settings_display.jsx4
-rw-r--r--webapp/components/user_settings/user_settings_general.jsx419
-rw-r--r--webapp/components/user_settings/user_settings_integrations.jsx90
-rw-r--r--webapp/components/user_settings/user_settings_modal.jsx14
-rw-r--r--webapp/components/user_settings/user_settings_notifications.jsx2
-rw-r--r--webapp/components/user_settings/user_settings_security.jsx465
-rw-r--r--webapp/components/user_settings/user_settings_theme.jsx13
-rw-r--r--webapp/dispatcher/app_dispatcher.jsx8
-rw-r--r--webapp/i18n/en.json127
-rw-r--r--webapp/i18n/es.json120
-rw-r--r--webapp/i18n/fr.json34
-rw-r--r--webapp/i18n/pt.json126
-rw-r--r--webapp/images/emoji/1f1e8-1f1e6.pngbin0 -> 4339 bytes
-rw-r--r--webapp/images/emoji/1f1f5-1f1f0.pngbin0 -> 4604 bytes
-rw-r--r--webapp/images/emoji/1f1ff-1e1e6.pngbin0 -> 6547 bytes
-rw-r--r--webapp/images/emoji/1f641.pngbin0 -> 6388 bytes
-rw-r--r--webapp/images/emoji/1f642.pngbin0 -> 6362 bytes
-rw-r--r--webapp/images/emoji/1f643.pngbin0 -> 6519 bytes
-rw-r--r--webapp/package.json4
-rw-r--r--webapp/root.jsx134
-rw-r--r--webapp/sass/components/_mentions.scss2
-rw-r--r--webapp/sass/components/_modal.scss32
-rw-r--r--webapp/sass/layout/_forms.scss2
-rw-r--r--webapp/sass/layout/_post.scss58
-rw-r--r--webapp/sass/layout/_sidebar-right.scss4
-rw-r--r--webapp/sass/responsive/_desktop.scss13
-rw-r--r--webapp/sass/responsive/_mobile.scss72
-rw-r--r--webapp/sass/responsive/_tablet.scss17
-rw-r--r--webapp/sass/routes/_about-modal.scss78
-rw-r--r--webapp/sass/routes/_backstage.scss267
-rw-r--r--webapp/sass/routes/_module.scss2
-rw-r--r--webapp/sass/routes/_settings.scss32
-rw-r--r--webapp/sass/routes/_signup.scss18
-rw-r--r--webapp/sass/styles.scss19
-rw-r--r--webapp/sass/utils/_variables.scss2
-rw-r--r--webapp/sass/vendors/_colorpicker.scss253
-rw-r--r--webapp/sass/vendors/_font-awesome.scss1803
-rw-r--r--webapp/sass/vendors/_module.scss4
-rwxr-xr-xwebapp/sass/vendors/_perfect-scrollbar.scss141
-rw-r--r--webapp/stores/browser_store.jsx10
-rw-r--r--webapp/stores/error_store.jsx1
-rw-r--r--webapp/stores/file_store.jsx4
-rw-r--r--webapp/stores/integration_store.jsx134
-rw-r--r--webapp/stores/notification_store.jsx (renamed from webapp/stores/notificaiton_store.jsx)2
-rw-r--r--webapp/stores/post_store.jsx12
-rw-r--r--webapp/stores/search_store.jsx2
-rw-r--r--webapp/utils/async_client.jsx137
-rw-r--r--webapp/utils/client.jsx46
-rw-r--r--webapp/utils/constants.jsx19
-rw-r--r--webapp/utils/delayed_action.jsx4
-rw-r--r--webapp/utils/emoji.json58
-rw-r--r--webapp/utils/emoticons.jsx4
-rw-r--r--webapp/utils/utils.jsx79
-rw-r--r--webapp/webpack.config.js18
176 files changed, 10038 insertions, 4434 deletions
diff --git a/.editorconfig b/.editorconfig
index 15dd92ecd..5325248da 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -14,7 +14,13 @@ indent_style = tab
indent_style = space
indent_size = 4
-[web/react/package.json]
+[webapp/package.json]
+indent_size = 2
+
+[i18n/**.json]
+indent_size = 2
+
+[webapp/i18n/**.json]
indent_size = 2
[Makefile]
diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json
index f94cafc1c..aff57a2af 100644
--- a/Godeps/Godeps.json
+++ b/Godeps/Godeps.json
@@ -23,6 +23,10 @@
"Rev": "bcc4c8345a21301bf47c032ff42dd1aae2fe3027"
},
{
+ "ImportPath": "github.com/dgryski/dgoogauth",
+ "Rev": "67642ac6f9144f6610279e37e7be9af13f1cd668"
+ },
+ {
"ImportPath": "github.com/disintegration/imaging",
"Rev": "546cb3c5137b3f1232e123a26aa033aade6b3066"
},
@@ -99,6 +103,14 @@
"Rev": "11fc39a580a008f1f39bb3d11d984fb34ed778d9"
},
{
+ "ImportPath": "github.com/mattermost/rsc/gf256",
+ "Rev": "bbaefb05eaa0389ea712340066837c8ce4d287f9"
+ },
+ {
+ "ImportPath": "github.com/mattermost/rsc/qr",
+ "Rev": "bbaefb05eaa0389ea712340066837c8ce4d287f9"
+ },
+ {
"ImportPath": "github.com/mssola/user_agent",
"Comment": "v0.4.1-5-g783ec61",
"Rev": "783ec61292aee3fc2f442ce740aa491e4849b794"
diff --git a/Godeps/_workspace/src/github.com/dgryski/dgoogauth/.travis.yml b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/.travis.yml
new file mode 100644
index 000000000..4f2ee4d97
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/.travis.yml
@@ -0,0 +1 @@
+language: go
diff --git a/Godeps/_workspace/src/github.com/dgryski/dgoogauth/README.md b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/README.md
new file mode 100644
index 000000000..75fdde78a
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/README.md
@@ -0,0 +1,15 @@
+This is a Go implementation of the Google Authenticator library.
+
+[![Build Status](https://travis-ci.org/dgryski/dgoogauth.png)](https://travis-ci.org/dgryski/dgoogauth)
+
+Copyright (c) 2012 Damian Gryski <damian@gryski.com>
+This code is licensed under the Apache License, version 2.0
+
+It implements the one-time-password algorithms specified in:
+
+* RFC 4226 (HOTP: An HMAC-Based One-Time Password Algorithm)
+* RFC 6238 (TOTP: Time-Based One-Time Password Algorithm)
+
+You can learn more about the Google Authenticator library at its project page:
+
+* https://github.com/google/google-authenticator
diff --git a/Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth.go b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth.go
new file mode 100644
index 000000000..1efddcc20
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth.go
@@ -0,0 +1,199 @@
+/*
+Package dgoogauth implements the one-time password algorithms supported by Google Authenticator
+
+This package supports the HMAC-Based One-time Password (HOTP) algorithm
+specified in RFC 4226 and the Time-based One-time Password (TOTP) algorithm
+specified in RFC 6238.
+*/
+package dgoogauth
+
+import (
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base32"
+ "encoding/binary"
+ "errors"
+ "net/url"
+ "sort"
+ "strconv"
+ "time"
+)
+
+// Much of this code assumes int == int64, which probably is not the case.
+
+// ComputeCode computes the response code for a 64-bit challenge 'value' using the secret 'secret'.
+// To avoid breaking compatibility with the previous API, it returns an invalid code (-1) when an error occurs,
+// but does not silently ignore them (it forces a mismatch so the code will be rejected).
+func ComputeCode(secret string, value int64) int {
+
+ key, err := base32.StdEncoding.DecodeString(secret)
+ if err != nil {
+ return -1
+ }
+
+ hash := hmac.New(sha1.New, key)
+ err = binary.Write(hash, binary.BigEndian, value)
+ if err != nil {
+ return -1
+ }
+ h := hash.Sum(nil)
+
+ offset := h[19] & 0x0f
+
+ truncated := binary.BigEndian.Uint32(h[offset : offset+4])
+
+ truncated &= 0x7fffffff
+ code := truncated % 1000000
+
+ return int(code)
+}
+
+// ErrInvalidCode indicate the supplied one-time code was not valid
+var ErrInvalidCode = errors.New("invalid code")
+
+// OTPConfig is a one-time-password configuration. This object will be modified by calls to
+// Authenticate and should be saved to ensure the codes are in fact only used
+// once.
+type OTPConfig struct {
+ Secret string // 80-bit base32 encoded string of the user's secret
+ WindowSize int // valid range: technically 0..100 or so, but beyond 3-5 is probably bad security
+ HotpCounter int // the current otp counter. 0 if the user uses time-based codes instead.
+ DisallowReuse []int // timestamps in the current window unavailable for re-use
+ ScratchCodes []int // an array of 8-digit numeric codes that can be used to log in
+ UTC bool // use UTC for the timestamp instead of local time
+}
+
+func (c *OTPConfig) checkScratchCodes(code int) bool {
+
+ for i, v := range c.ScratchCodes {
+ if code == v {
+ // remove this code from the list of valid ones
+ l := len(c.ScratchCodes) - 1
+ c.ScratchCodes[i] = c.ScratchCodes[l] // copy last element over this element
+ c.ScratchCodes = c.ScratchCodes[0:l] // and trim the list length by 1
+ return true
+ }
+ }
+
+ return false
+}
+
+func (c *OTPConfig) checkHotpCode(code int) bool {
+
+ for i := 0; i < c.WindowSize; i++ {
+ if ComputeCode(c.Secret, int64(c.HotpCounter+i)) == code {
+ c.HotpCounter += i + 1
+ // We don't check for overflow here, which means you can only authenticate 2^63 times
+ // After that, the counter is negative and the above 'if' test will fail.
+ // This matches the behaviour of the PAM module.
+ return true
+ }
+ }
+
+ // we must always advance the counter if we tried to authenticate with it
+ c.HotpCounter++
+ return false
+}
+
+func (c *OTPConfig) checkTotpCode(t0, code int) bool {
+
+ minT := t0 - (c.WindowSize / 2)
+ maxT := t0 + (c.WindowSize / 2)
+ for t := minT; t <= maxT; t++ {
+ if ComputeCode(c.Secret, int64(t)) == code {
+
+ if c.DisallowReuse != nil {
+ for _, timeCode := range c.DisallowReuse {
+ if timeCode == t {
+ return false
+ }
+ }
+
+ // code hasn't been used before
+ c.DisallowReuse = append(c.DisallowReuse, t)
+
+ // remove all time codes outside of the valid window
+ sort.Ints(c.DisallowReuse)
+ min := 0
+ for c.DisallowReuse[min] < minT {
+ min++
+ }
+ // FIXME: check we don't have an off-by-one error here
+ c.DisallowReuse = c.DisallowReuse[min:]
+ }
+
+ return true
+ }
+ }
+
+ return false
+}
+
+// Authenticate a one-time-password against the given OTPConfig
+// Returns true/false if the authentication was successful.
+// Returns error if the password is incorrectly formatted (not a zero-padded 6 or non-zero-padded 8 digit number).
+func (c *OTPConfig) Authenticate(password string) (bool, error) {
+
+ var scratch bool
+
+ switch {
+ case len(password) == 6 && password[0] >= '0' && password[0] <= '9':
+ break
+ case len(password) == 8 && password[0] >= '1' && password[0] <= '9':
+ scratch = true
+ break
+ default:
+ return false, ErrInvalidCode
+ }
+
+ code, err := strconv.Atoi(password)
+
+ if err != nil {
+ return false, ErrInvalidCode
+ }
+
+ if scratch {
+ return c.checkScratchCodes(code), nil
+ }
+
+ // we have a counter value we can use
+ if c.HotpCounter > 0 {
+ return c.checkHotpCode(code), nil
+ }
+
+ var t0 int
+ // assume we're on Time-based OTP
+ if c.UTC {
+ t0 = int(time.Now().UTC().Unix() / 30)
+ } else {
+ t0 = int(time.Now().Unix() / 30)
+ }
+ return c.checkTotpCode(t0, code), nil
+}
+
+// ProvisionURI generates a URI that can be turned into a QR code to configure
+// a Google Authenticator mobile app.
+func (c *OTPConfig) ProvisionURI(user string) string {
+ return c.ProvisionURIWithIssuer(user, "")
+}
+
+// ProvisionURIWithIssuer generates a URI that can be turned into a QR code
+// to configure a Google Authenticator mobile app. It respects the recommendations
+// on how to avoid conflicting accounts.
+//
+// See https://code.google.com/p/google-authenticator/wiki/ConflictingAccounts
+func (c *OTPConfig) ProvisionURIWithIssuer(user string, issuer string) string {
+ auth := "totp/"
+ q := make(url.Values)
+ if c.HotpCounter > 0 {
+ auth = "hotp/"
+ q.Add("counter", strconv.Itoa(c.HotpCounter))
+ }
+ q.Add("secret", c.Secret)
+ if issuer != "" {
+ q.Add("issuer", issuer)
+ auth += issuer + ":"
+ }
+
+ return "otpauth://" + auth + user + "?" + q.Encode()
+}
diff --git a/Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth_test.go b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth_test.go
new file mode 100644
index 000000000..031922c47
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/dgryski/dgoogauth/googauth_test.go
@@ -0,0 +1,251 @@
+package dgoogauth
+
+import (
+ "strconv"
+ "testing"
+ "time"
+)
+
+// Test vectors via:
+// http://code.google.com/p/google-authenticator/source/browse/libpam/pam_google_authenticator_unittest.c
+// https://google-authenticator.googlecode.com/hg/libpam/totp.html
+
+var codeTests = []struct {
+ secret string
+ value int64
+ code int
+}{
+ {"2SH3V3GDW7ZNMGYE", 1, 293240},
+ {"2SH3V3GDW7ZNMGYE", 5, 932068},
+ {"2SH3V3GDW7ZNMGYE", 10000, 50548},
+}
+
+func TestCode(t *testing.T) {
+
+ for _, v := range codeTests {
+ c := ComputeCode(v.secret, v.value)
+
+ if c != v.code {
+ t.Errorf("computeCode(%s, %d): got %d expected %d\n", v.secret, v.value, c, v.code)
+ }
+
+ }
+}
+
+func TestScratchCode(t *testing.T) {
+
+ var cotp OTPConfig
+
+ cotp.ScratchCodes = []int{11112222, 22223333}
+
+ var scratchTests = []struct {
+ code int
+ result bool
+ }{
+ {33334444, false},
+ {11112222, true},
+ {11112222, false},
+ {22223333, true},
+ {22223333, false},
+ {33334444, false},
+ }
+
+ for _, s := range scratchTests {
+ r := cotp.checkScratchCodes(s.code)
+ if r != s.result {
+ t.Errorf("scratchcode(%d) failed: got %t expected %t", s.code, r, s.result)
+ }
+ }
+}
+
+func TestHotpCode(t *testing.T) {
+
+ var cotp OTPConfig
+
+ // reuse our test values from above
+ // perhaps create more?
+ cotp.Secret = "2SH3V3GDW7ZNMGYE"
+ cotp.HotpCounter = 1
+ cotp.WindowSize = 3
+
+ var counterCodes = []struct {
+ code int
+ result bool
+ counter int
+ }{
+ { /* 1 */ 293240, true, 2}, // increments on success
+ { /* 1 */ 293240, false, 3}, // and failure
+ { /* 5 */ 932068, true, 6}, // inside of window
+ { /* 10 */ 481725, false, 7}, // outside of window
+ { /* 10 */ 481725, false, 8}, // outside of window
+ { /* 10 */ 481725, true, 11}, // now inside of window
+ }
+
+ for i, s := range counterCodes {
+ r := cotp.checkHotpCode(s.code)
+ if r != s.result {
+ t.Errorf("counterCode(%d) (step %d) failed: got %t expected %t", s.code, i, r, s.result)
+ }
+ if cotp.HotpCounter != s.counter {
+ t.Errorf("hotpCounter incremented poorly: got %d expected %d", cotp.HotpCounter, s.counter)
+ }
+ }
+}
+
+func TestTotpCode(t *testing.T) {
+
+ var cotp OTPConfig
+
+ // reuse our test values from above
+ cotp.Secret = "2SH3V3GDW7ZNMGYE"
+ cotp.WindowSize = 5
+
+ var windowTest = []struct {
+ code int
+ t0 int
+ result bool
+ }{
+ {50548, 9997, false},
+ {50548, 9998, true},
+ {50548, 9999, true},
+ {50548, 10000, true},
+ {50548, 10001, true},
+ {50548, 10002, true},
+ {50548, 10003, false},
+ }
+
+ for i, s := range windowTest {
+ r := cotp.checkTotpCode(s.t0, s.code)
+ if r != s.result {
+ t.Errorf("counterCode(%d) (step %d) failed: got %t expected %t", s.code, i, r, s.result)
+ }
+ }
+
+ cotp.DisallowReuse = make([]int, 0)
+ var noreuseTest = []struct {
+ code int
+ t0 int
+ result bool
+ disallowed []int
+ }{
+ {50548 /* 10000 */, 9997, false, []int{}},
+ {50548 /* 10000 */, 9998, true, []int{10000}},
+ {50548 /* 10000 */, 9999, false, []int{10000}},
+ {478726 /* 10001 */, 10001, true, []int{10000, 10001}},
+ {646986 /* 10002 */, 10002, true, []int{10000, 10001, 10002}},
+ {842639 /* 10003 */, 10003, true, []int{10001, 10002, 10003}},
+ }
+
+ for i, s := range noreuseTest {
+ r := cotp.checkTotpCode(s.t0, s.code)
+ if r != s.result {
+ t.Errorf("timeCode(%d) (step %d) failed: got %t expected %t", s.code, i, r, s.result)
+ }
+ if len(cotp.DisallowReuse) != len(s.disallowed) {
+ t.Errorf("timeCode(%d) (step %d) failed: disallowReuse len mismatch: got %d expected %d", s.code, i, len(cotp.DisallowReuse), len(s.disallowed))
+ } else {
+ same := true
+ for j := range s.disallowed {
+ if s.disallowed[j] != cotp.DisallowReuse[j] {
+ same = false
+ }
+ }
+ if !same {
+ t.Errorf("timeCode(%d) (step %d) failed: disallowReused: got %v expected %v", s.code, i, cotp.DisallowReuse, s.disallowed)
+ }
+ }
+ }
+}
+
+func TestAuthenticate(t *testing.T) {
+
+ otpconf := &OTPConfig{
+ Secret: "2SH3V3GDW7ZNMGYE",
+ WindowSize: 3,
+ HotpCounter: 1,
+ ScratchCodes: []int{11112222, 22223333},
+ }
+
+ type attempt struct {
+ code string
+ result bool
+ }
+
+ var attempts = []attempt{
+ {"foobar", false}, // not digits
+ {"1fooba", false}, // not valid number
+ {"1111111", false}, // bad length
+ { /* 1 */ "293240", true}, // hopt increments on success
+ { /* 1 */ "293240", false}, // hopt failure
+ {"33334444", false}, // scratch
+ {"11112222", true},
+ {"11112222", false},
+ }
+
+ for _, a := range attempts {
+ r, _ := otpconf.Authenticate(a.code)
+ if r != a.result {
+ t.Errorf("bad result from code=%s: got %t expected %t\n", a.code, r, a.result)
+ }
+ }
+
+ // let's check some time-based codes
+ otpconf.HotpCounter = 0
+ // I haven't mocked the clock, so we'll just compute one
+ var t0 int64
+ if otpconf.UTC {
+ t0 = int64(time.Now().UTC().Unix() / 30)
+ } else {
+ t0 = int64(time.Now().Unix() / 30)
+ }
+ c := ComputeCode(otpconf.Secret, t0)
+
+ invalid := c + 1
+ attempts = []attempt{
+ {strconv.Itoa(invalid), false},
+ {strconv.Itoa(c), true},
+ }
+
+ for _, a := range attempts {
+ r, _ := otpconf.Authenticate(a.code)
+ if r != a.result {
+ t.Errorf("bad result from code=%s: got %t expected %t\n", a.code, r, a.result)
+ }
+
+ otpconf.UTC = true
+ r, _ = otpconf.Authenticate(a.code)
+ if r != a.result {
+ t.Errorf("bad result from code=%s: got %t expected %t\n", a.code, r, a.result)
+ }
+ otpconf.UTC = false
+ }
+
+}
+
+func TestProvisionURI(t *testing.T) {
+ otpconf := OTPConfig{
+ Secret: "x",
+ }
+
+ cases := []struct {
+ user, iss string
+ hotp bool
+ out string
+ }{
+ {"test", "", false, "otpauth://totp/test?secret=x"},
+ {"test", "", true, "otpauth://hotp/test?counter=1&secret=x"},
+ {"test", "Company", true, "otpauth://hotp/Company:test?counter=1&issuer=Company&secret=x"},
+ {"test", "Company", false, "otpauth://totp/Company:test?issuer=Company&secret=x"},
+ }
+
+ for i, c := range cases {
+ otpconf.HotpCounter = 0
+ if c.hotp {
+ otpconf.HotpCounter = 1
+ }
+ got := otpconf.ProvisionURIWithIssuer(c.user, c.iss)
+ if got != c.out {
+ t.Errorf("%d: want %q, got %q", i, c.out, got)
+ }
+ }
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/Makefile b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/Makefile
new file mode 100644
index 000000000..518a034f3
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/Makefile
@@ -0,0 +1,8 @@
+# Copyright 2010 The Go Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+include $(GOROOT)/src/Make.inc
+TARG=rsc.googlecode.com/hg/gf256
+GOFILES=gf256.go #rs.go
+include $(GOROOT)/src/Make.pkg
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/blog_test.go b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/blog_test.go
new file mode 100644
index 000000000..12cc7deb0
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/blog_test.go
@@ -0,0 +1,85 @@
+// Copyright 2012 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// This file contains a straightforward implementation of
+// Reed-Solomon encoding, along with a benchmark.
+// It goes with http://research.swtch.com/field.
+//
+// For an optimized implementation, see gf256.go.
+
+package gf256
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+)
+
+// BlogECC writes to check the error correcting code bytes
+// for data using the given Reed-Solomon parameters.
+func BlogECC(rs *RSEncoder, m []byte, check []byte) {
+ if len(check) < rs.c {
+ panic("gf256: invalid check byte length")
+ }
+ if rs.c == 0 {
+ return
+ }
+
+ // The check bytes are the remainder after dividing
+ // data padded with c zeros by the generator polynomial.
+
+ // p = data padded with c zeros.
+ var p []byte
+ n := len(m) + rs.c
+ if len(rs.p) >= n {
+ p = rs.p
+ } else {
+ p = make([]byte, n)
+ }
+ copy(p, m)
+ for i := len(m); i < len(p); i++ {
+ p[i] = 0
+ }
+
+ gen := rs.gen
+
+ // Divide p by gen, leaving the remainder in p[len(data):].
+ // p[0] is the most significant term in p, and
+ // gen[0] is the most significant term in the generator.
+ for i := 0; i < len(m); i++ {
+ k := f.Mul(p[i], f.Inv(gen[0])) // k = pi / g0
+ // p -= kΒ·g
+ for j, g := range gen {
+ p[i+j] = f.Add(p[i+j], f.Mul(k, g))
+ }
+ }
+
+ copy(check, p[len(m):])
+ rs.p = p
+}
+
+func BenchmarkBlogECC(b *testing.B) {
+ data := []byte{0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11}
+ check := []byte{0x29, 0x41, 0xb3, 0x93, 0x8, 0xe8, 0xa3, 0xe7, 0x63, 0x8f}
+ out := make([]byte, len(check))
+ rs := NewRSEncoder(f, len(check))
+ for i := 0; i < b.N; i++ {
+ BlogECC(rs, data, out)
+ }
+ b.SetBytes(int64(len(data)))
+ if !bytes.Equal(out, check) {
+ fmt.Printf("have %#v want %#v\n", out, check)
+ }
+}
+
+func TestBlogECC(t *testing.T) {
+ data := []byte{0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11}
+ check := []byte{0xa5, 0x24, 0xd4, 0xc1, 0xed, 0x36, 0xc7, 0x87, 0x2c, 0x55}
+ out := make([]byte, len(check))
+ rs := NewRSEncoder(f, len(check))
+ BlogECC(rs, data, out)
+ if !bytes.Equal(out, check) {
+ t.Errorf("have %x want %x", out, check)
+ }
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256.go b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256.go
new file mode 100644
index 000000000..34cc975a8
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256.go
@@ -0,0 +1,241 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package gf256 implements arithmetic over the Galois Field GF(256).
+package gf256
+
+import "strconv"
+
+// A Field represents an instance of GF(256) defined by a specific polynomial.
+type Field struct {
+ log [256]byte // log[0] is unused
+ exp [510]byte
+}
+
+// NewField returns a new field corresponding to the polynomial poly
+// and generator Ξ±. The Reed-Solomon encoding in QR codes uses
+// polynomial 0x11d with generator 2.
+//
+// The choice of generator Ξ± only affects the Exp and Log operations.
+func NewField(poly, Ξ± int) *Field {
+ if poly < 0x100 || poly >= 0x200 || reducible(poly) {
+ panic("gf256: invalid polynomial: " + strconv.Itoa(poly))
+ }
+
+ var f Field
+ x := 1
+ for i := 0; i < 255; i++ {
+ if x == 1 && i != 0 {
+ panic("gf256: invalid generator " + strconv.Itoa(Ξ±) +
+ " for polynomial " + strconv.Itoa(poly))
+ }
+ f.exp[i] = byte(x)
+ f.exp[i+255] = byte(x)
+ f.log[x] = byte(i)
+ x = mul(x, Ξ±, poly)
+ }
+ f.log[0] = 255
+ for i := 0; i < 255; i++ {
+ if f.log[f.exp[i]] != byte(i) {
+ panic("bad log")
+ }
+ if f.log[f.exp[i+255]] != byte(i) {
+ panic("bad log")
+ }
+ }
+ for i := 1; i < 256; i++ {
+ if f.exp[f.log[i]] != byte(i) {
+ panic("bad log")
+ }
+ }
+
+ return &f
+}
+
+// nbit returns the number of significant in p.
+func nbit(p int) uint {
+ n := uint(0)
+ for ; p > 0; p >>= 1 {
+ n++
+ }
+ return n
+}
+
+// polyDiv divides the polynomial p by q and returns the remainder.
+func polyDiv(p, q int) int {
+ np := nbit(p)
+ nq := nbit(q)
+ for ; np >= nq; np-- {
+ if p&(1<<(np-1)) != 0 {
+ p ^= q << (np - nq)
+ }
+ }
+ return p
+}
+
+// mul returns the product x*y mod poly, a GF(256) multiplication.
+func mul(x, y, poly int) int {
+ z := 0
+ for x > 0 {
+ if x&1 != 0 {
+ z ^= y
+ }
+ x >>= 1
+ y <<= 1
+ if y&0x100 != 0 {
+ y ^= poly
+ }
+ }
+ return z
+}
+
+// reducible reports whether p is reducible.
+func reducible(p int) bool {
+ // Multiplying n-bit * n-bit produces (2n-1)-bit,
+ // so if p is reducible, one of its factors must be
+ // of np/2+1 bits or fewer.
+ np := nbit(p)
+ for q := 2; q < 1<<(np/2+1); q++ {
+ if polyDiv(p, q) == 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// Add returns the sum of x and y in the field.
+func (f *Field) Add(x, y byte) byte {
+ return x ^ y
+}
+
+// Exp returns the the base-Ξ± exponential of e in the field.
+// If e < 0, Exp returns 0.
+func (f *Field) Exp(e int) byte {
+ if e < 0 {
+ return 0
+ }
+ return f.exp[e%255]
+}
+
+// Log returns the base-Ξ± logarithm of x in the field.
+// If x == 0, Log returns -1.
+func (f *Field) Log(x byte) int {
+ if x == 0 {
+ return -1
+ }
+ return int(f.log[x])
+}
+
+// Inv returns the multiplicative inverse of x in the field.
+// If x == 0, Inv returns 0.
+func (f *Field) Inv(x byte) byte {
+ if x == 0 {
+ return 0
+ }
+ return f.exp[255-f.log[x]]
+}
+
+// Mul returns the product of x and y in the field.
+func (f *Field) Mul(x, y byte) byte {
+ if x == 0 || y == 0 {
+ return 0
+ }
+ return f.exp[int(f.log[x])+int(f.log[y])]
+}
+
+// An RSEncoder implements Reed-Solomon encoding
+// over a given field using a given number of error correction bytes.
+type RSEncoder struct {
+ f *Field
+ c int
+ gen []byte
+ lgen []byte
+ p []byte
+}
+
+func (f *Field) gen(e int) (gen, lgen []byte) {
+ // p = 1
+ p := make([]byte, e+1)
+ p[e] = 1
+
+ for i := 0; i < e; i++ {
+ // p *= (x + Exp(i))
+ // p[j] = p[j]*Exp(i) + p[j+1].
+ c := f.Exp(i)
+ for j := 0; j < e; j++ {
+ p[j] = f.Mul(p[j], c) ^ p[j+1]
+ }
+ p[e] = f.Mul(p[e], c)
+ }
+
+ // lp = log p.
+ lp := make([]byte, e+1)
+ for i, c := range p {
+ if c == 0 {
+ lp[i] = 255
+ } else {
+ lp[i] = byte(f.Log(c))
+ }
+ }
+
+ return p, lp
+}
+
+// NewRSEncoder returns a new Reed-Solomon encoder
+// over the given field and number of error correction bytes.
+func NewRSEncoder(f *Field, c int) *RSEncoder {
+ gen, lgen := f.gen(c)
+ return &RSEncoder{f: f, c: c, gen: gen, lgen: lgen}
+}
+
+// ECC writes to check the error correcting code bytes
+// for data using the given Reed-Solomon parameters.
+func (rs *RSEncoder) ECC(data []byte, check []byte) {
+ if len(check) < rs.c {
+ panic("gf256: invalid check byte length")
+ }
+ if rs.c == 0 {
+ return
+ }
+
+ // The check bytes are the remainder after dividing
+ // data padded with c zeros by the generator polynomial.
+
+ // p = data padded with c zeros.
+ var p []byte
+ n := len(data) + rs.c
+ if len(rs.p) >= n {
+ p = rs.p
+ } else {
+ p = make([]byte, n)
+ }
+ copy(p, data)
+ for i := len(data); i < len(p); i++ {
+ p[i] = 0
+ }
+
+ // Divide p by gen, leaving the remainder in p[len(data):].
+ // p[0] is the most significant term in p, and
+ // gen[0] is the most significant term in the generator,
+ // which is always 1.
+ // To avoid repeated work, we store various values as
+ // lv, not v, where lv = log[v].
+ f := rs.f
+ lgen := rs.lgen[1:]
+ for i := 0; i < len(data); i++ {
+ c := p[i]
+ if c == 0 {
+ continue
+ }
+ q := p[i+1:]
+ exp := f.exp[f.log[c]:]
+ for j, lg := range lgen {
+ if lg != 255 { // lgen uses 255 for log 0
+ q[j] ^= exp[lg]
+ }
+ }
+ }
+ copy(check, p[len(data):])
+ rs.p = p
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256_test.go b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256_test.go
new file mode 100644
index 000000000..f77fa7d67
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/gf256/gf256_test.go
@@ -0,0 +1,194 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package gf256
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+)
+
+var f = NewField(0x11d, 2) // x^8 + x^4 + x^3 + x^2 + 1
+
+func TestBasic(t *testing.T) {
+ if f.Exp(0) != 1 || f.Exp(1) != 2 || f.Exp(255) != 1 {
+ panic("bad Exp")
+ }
+}
+
+func TestECC(t *testing.T) {
+ data := []byte{0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11}
+ check := []byte{0xa5, 0x24, 0xd4, 0xc1, 0xed, 0x36, 0xc7, 0x87, 0x2c, 0x55}
+ out := make([]byte, len(check))
+ rs := NewRSEncoder(f, len(check))
+ rs.ECC(data, out)
+ if !bytes.Equal(out, check) {
+ t.Errorf("have %x want %x", out, check)
+ }
+}
+
+func TestLinear(t *testing.T) {
+ d1 := []byte{0x00, 0x00}
+ c1 := []byte{0x00, 0x00}
+ out := make([]byte, len(c1))
+ rs := NewRSEncoder(f, len(c1))
+ if rs.ECC(d1, out); !bytes.Equal(out, c1) {
+ t.Errorf("ECBytes(%x, %d) = %x, want 0", d1, len(c1), out)
+ }
+ d2 := []byte{0x00, 0x01}
+ c2 := make([]byte, 2)
+ rs.ECC(d2, c2)
+ d3 := []byte{0x00, 0x02}
+ c3 := make([]byte, 2)
+ rs.ECC(d3, c3)
+ cx := make([]byte, 2)
+ for i := range cx {
+ cx[i] = c2[i] ^ c3[i]
+ }
+ d4 := []byte{0x00, 0x03}
+ c4 := make([]byte, 2)
+ rs.ECC(d4, c4)
+ if !bytes.Equal(cx, c4) {
+ t.Errorf("ECBytes(%x, 2) = %x\nECBytes(%x, 2) = %x\nxor = %x\nECBytes(%x, 2) = %x",
+ d2, c2, d3, c3, cx, d4, c4)
+ }
+}
+
+func TestGaussJordan(t *testing.T) {
+ rs := NewRSEncoder(f, 2)
+ m := make([][]byte, 16)
+ for i := range m {
+ m[i] = make([]byte, 4)
+ m[i][i/8] = 1 << uint(i%8)
+ rs.ECC(m[i][:2], m[i][2:])
+ }
+ if false {
+ fmt.Printf("---\n")
+ for _, row := range m {
+ fmt.Printf("%x\n", row)
+ }
+ }
+ b := []uint{0, 1, 2, 3, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 26, 27}
+ for i := 0; i < 16; i++ {
+ bi := b[i]
+ if m[i][bi/8]&(1<<(7-bi%8)) == 0 {
+ for j := i + 1; ; j++ {
+ if j >= len(m) {
+ t.Errorf("lost track for %d", bi)
+ break
+ }
+ if m[j][bi/8]&(1<<(7-bi%8)) != 0 {
+ m[i], m[j] = m[j], m[i]
+ break
+ }
+ }
+ }
+ for j := i + 1; j < len(m); j++ {
+ if m[j][bi/8]&(1<<(7-bi%8)) != 0 {
+ for k := range m[j] {
+ m[j][k] ^= m[i][k]
+ }
+ }
+ }
+ }
+ if false {
+ fmt.Printf("---\n")
+ for _, row := range m {
+ fmt.Printf("%x\n", row)
+ }
+ }
+ for i := 15; i >= 0; i-- {
+ bi := b[i]
+ for j := i - 1; j >= 0; j-- {
+ if m[j][bi/8]&(1<<(7-bi%8)) != 0 {
+ for k := range m[j] {
+ m[j][k] ^= m[i][k]
+ }
+ }
+ }
+ }
+ if false {
+ fmt.Printf("---\n")
+ for _, row := range m {
+ fmt.Printf("%x", row)
+ out := make([]byte, 2)
+ if rs.ECC(row[:2], out); !bytes.Equal(out, row[2:]) {
+ fmt.Printf(" - want %x", out)
+ }
+ fmt.Printf("\n")
+ }
+ }
+}
+
+func BenchmarkECC(b *testing.B) {
+ data := []byte{0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11}
+ check := []byte{0x29, 0x41, 0xb3, 0x93, 0x8, 0xe8, 0xa3, 0xe7, 0x63, 0x8f}
+ out := make([]byte, len(check))
+ rs := NewRSEncoder(f, len(check))
+ for i := 0; i < b.N; i++ {
+ rs.ECC(data, out)
+ }
+ b.SetBytes(int64(len(data)))
+ if !bytes.Equal(out, check) {
+ fmt.Printf("have %#v want %#v\n", out, check)
+ }
+}
+
+func TestGen(t *testing.T) {
+ for i := 0; i < 256; i++ {
+ _, lg := f.gen(i)
+ if lg[0] != 0 {
+ t.Errorf("#%d: %x", i, lg)
+ }
+ }
+}
+
+func TestReducible(t *testing.T) {
+ var count = []int{1, 2, 3, 6, 9, 18, 30, 56, 99, 186} // oeis.org/A1037
+ for i, want := range count {
+ n := 0
+ for p := 1 << uint(i+2); p < 1<<uint(i+3); p++ {
+ if !reducible(p) {
+ n++
+ }
+ }
+ if n != want {
+ t.Errorf("#reducible(%d-bit) = %d, want %d", i+2, n, want)
+ }
+ }
+}
+
+func TestExhaustive(t *testing.T) {
+ for poly := 0x100; poly < 0x200; poly++ {
+ if reducible(poly) {
+ continue
+ }
+ Ξ± := 2
+ for !generates(Ξ±, poly) {
+ Ξ±++
+ }
+ f := NewField(poly, Ξ±)
+ for p := 0; p < 256; p++ {
+ for q := 0; q < 256; q++ {
+ fm := int(f.Mul(byte(p), byte(q)))
+ pm := mul(p, q, poly)
+ if fm != pm {
+ t.Errorf("NewField(%#x).Mul(%#x, %#x) = %#x, want %#x", poly, p, q, fm, pm)
+ }
+ }
+ }
+ }
+}
+
+func generates(Ξ±, poly int) bool {
+ x := Ξ±
+ for i := 0; i < 254; i++ {
+ if x == 1 {
+ return false
+ }
+ x = mul(x, Ξ±, poly)
+ }
+ return true
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/Makefile b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/Makefile
new file mode 100644
index 000000000..d00c470bb
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/Makefile
@@ -0,0 +1,4 @@
+include $(GOROOT)/src/Make.inc
+TARG=rsc.googlecode.com/hg/qr
+GOFILES=qr.go png.go
+include $(GOROOT)/src/Make.pkg
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/Makefile b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/Makefile
new file mode 100644
index 000000000..5d1c4d307
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/Makefile
@@ -0,0 +1,7 @@
+include $(GOROOT)/src/Make.inc
+
+TARG=rsc.googlecode.com/hg/qr/coding
+GOFILES=\
+ qr.go\
+
+include $(GOROOT)/src/Make.pkg
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/gen.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/gen.go
new file mode 100644
index 000000000..a3857f277
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/gen.go
@@ -0,0 +1,149 @@
+// +build ignore
+
+package main
+
+import "fmt"
+
+// tables from qrencode-3.1.1/qrspec.c
+
+var capacity = [41]struct {
+ width int
+ words int
+ remainder int
+ ec [4]int
+}{
+ {0, 0, 0, [4]int{0, 0, 0, 0}},
+ {21, 26, 0, [4]int{7, 10, 13, 17}}, // 1
+ {25, 44, 7, [4]int{10, 16, 22, 28}},
+ {29, 70, 7, [4]int{15, 26, 36, 44}},
+ {33, 100, 7, [4]int{20, 36, 52, 64}},
+ {37, 134, 7, [4]int{26, 48, 72, 88}}, // 5
+ {41, 172, 7, [4]int{36, 64, 96, 112}},
+ {45, 196, 0, [4]int{40, 72, 108, 130}},
+ {49, 242, 0, [4]int{48, 88, 132, 156}},
+ {53, 292, 0, [4]int{60, 110, 160, 192}},
+ {57, 346, 0, [4]int{72, 130, 192, 224}}, //10
+ {61, 404, 0, [4]int{80, 150, 224, 264}},
+ {65, 466, 0, [4]int{96, 176, 260, 308}},
+ {69, 532, 0, [4]int{104, 198, 288, 352}},
+ {73, 581, 3, [4]int{120, 216, 320, 384}},
+ {77, 655, 3, [4]int{132, 240, 360, 432}}, //15
+ {81, 733, 3, [4]int{144, 280, 408, 480}},
+ {85, 815, 3, [4]int{168, 308, 448, 532}},
+ {89, 901, 3, [4]int{180, 338, 504, 588}},
+ {93, 991, 3, [4]int{196, 364, 546, 650}},
+ {97, 1085, 3, [4]int{224, 416, 600, 700}}, //20
+ {101, 1156, 4, [4]int{224, 442, 644, 750}},
+ {105, 1258, 4, [4]int{252, 476, 690, 816}},
+ {109, 1364, 4, [4]int{270, 504, 750, 900}},
+ {113, 1474, 4, [4]int{300, 560, 810, 960}},
+ {117, 1588, 4, [4]int{312, 588, 870, 1050}}, //25
+ {121, 1706, 4, [4]int{336, 644, 952, 1110}},
+ {125, 1828, 4, [4]int{360, 700, 1020, 1200}},
+ {129, 1921, 3, [4]int{390, 728, 1050, 1260}},
+ {133, 2051, 3, [4]int{420, 784, 1140, 1350}},
+ {137, 2185, 3, [4]int{450, 812, 1200, 1440}}, //30
+ {141, 2323, 3, [4]int{480, 868, 1290, 1530}},
+ {145, 2465, 3, [4]int{510, 924, 1350, 1620}},
+ {149, 2611, 3, [4]int{540, 980, 1440, 1710}},
+ {153, 2761, 3, [4]int{570, 1036, 1530, 1800}},
+ {157, 2876, 0, [4]int{570, 1064, 1590, 1890}}, //35
+ {161, 3034, 0, [4]int{600, 1120, 1680, 1980}},
+ {165, 3196, 0, [4]int{630, 1204, 1770, 2100}},
+ {169, 3362, 0, [4]int{660, 1260, 1860, 2220}},
+ {173, 3532, 0, [4]int{720, 1316, 1950, 2310}},
+ {177, 3706, 0, [4]int{750, 1372, 2040, 2430}}, //40
+}
+
+var eccTable = [41][4][2]int{
+ {{0, 0}, {0, 0}, {0, 0}, {0, 0}},
+ {{1, 0}, {1, 0}, {1, 0}, {1, 0}}, // 1
+ {{1, 0}, {1, 0}, {1, 0}, {1, 0}},
+ {{1, 0}, {1, 0}, {2, 0}, {2, 0}},
+ {{1, 0}, {2, 0}, {2, 0}, {4, 0}},
+ {{1, 0}, {2, 0}, {2, 2}, {2, 2}}, // 5
+ {{2, 0}, {4, 0}, {4, 0}, {4, 0}},
+ {{2, 0}, {4, 0}, {2, 4}, {4, 1}},
+ {{2, 0}, {2, 2}, {4, 2}, {4, 2}},
+ {{2, 0}, {3, 2}, {4, 4}, {4, 4}},
+ {{2, 2}, {4, 1}, {6, 2}, {6, 2}}, //10
+ {{4, 0}, {1, 4}, {4, 4}, {3, 8}},
+ {{2, 2}, {6, 2}, {4, 6}, {7, 4}},
+ {{4, 0}, {8, 1}, {8, 4}, {12, 4}},
+ {{3, 1}, {4, 5}, {11, 5}, {11, 5}},
+ {{5, 1}, {5, 5}, {5, 7}, {11, 7}}, //15
+ {{5, 1}, {7, 3}, {15, 2}, {3, 13}},
+ {{1, 5}, {10, 1}, {1, 15}, {2, 17}},
+ {{5, 1}, {9, 4}, {17, 1}, {2, 19}},
+ {{3, 4}, {3, 11}, {17, 4}, {9, 16}},
+ {{3, 5}, {3, 13}, {15, 5}, {15, 10}}, //20
+ {{4, 4}, {17, 0}, {17, 6}, {19, 6}},
+ {{2, 7}, {17, 0}, {7, 16}, {34, 0}},
+ {{4, 5}, {4, 14}, {11, 14}, {16, 14}},
+ {{6, 4}, {6, 14}, {11, 16}, {30, 2}},
+ {{8, 4}, {8, 13}, {7, 22}, {22, 13}}, //25
+ {{10, 2}, {19, 4}, {28, 6}, {33, 4}},
+ {{8, 4}, {22, 3}, {8, 26}, {12, 28}},
+ {{3, 10}, {3, 23}, {4, 31}, {11, 31}},
+ {{7, 7}, {21, 7}, {1, 37}, {19, 26}},
+ {{5, 10}, {19, 10}, {15, 25}, {23, 25}}, //30
+ {{13, 3}, {2, 29}, {42, 1}, {23, 28}},
+ {{17, 0}, {10, 23}, {10, 35}, {19, 35}},
+ {{17, 1}, {14, 21}, {29, 19}, {11, 46}},
+ {{13, 6}, {14, 23}, {44, 7}, {59, 1}},
+ {{12, 7}, {12, 26}, {39, 14}, {22, 41}}, //35
+ {{6, 14}, {6, 34}, {46, 10}, {2, 64}},
+ {{17, 4}, {29, 14}, {49, 10}, {24, 46}},
+ {{4, 18}, {13, 32}, {48, 14}, {42, 32}},
+ {{20, 4}, {40, 7}, {43, 22}, {10, 67}},
+ {{19, 6}, {18, 31}, {34, 34}, {20, 61}}, //40
+}
+
+var align = [41][2]int{
+ {0, 0},
+ {0, 0}, {18, 0}, {22, 0}, {26, 0}, {30, 0}, // 1- 5
+ {34, 0}, {22, 38}, {24, 42}, {26, 46}, {28, 50}, // 6-10
+ {30, 54}, {32, 58}, {34, 62}, {26, 46}, {26, 48}, //11-15
+ {26, 50}, {30, 54}, {30, 56}, {30, 58}, {34, 62}, //16-20
+ {28, 50}, {26, 50}, {30, 54}, {28, 54}, {32, 58}, //21-25
+ {30, 58}, {34, 62}, {26, 50}, {30, 54}, {26, 52}, //26-30
+ {30, 56}, {34, 60}, {30, 58}, {34, 62}, {30, 54}, //31-35
+ {24, 50}, {28, 54}, {32, 58}, {26, 54}, {30, 58}, //35-40
+}
+
+var versionPattern = [41]int{
+ 0,
+ 0, 0, 0, 0, 0, 0,
+ 0x07c94, 0x085bc, 0x09a99, 0x0a4d3, 0x0bbf6, 0x0c762, 0x0d847, 0x0e60d,
+ 0x0f928, 0x10b78, 0x1145d, 0x12a17, 0x13532, 0x149a6, 0x15683, 0x168c9,
+ 0x177ec, 0x18ec4, 0x191e1, 0x1afab, 0x1b08e, 0x1cc1a, 0x1d33f, 0x1ed75,
+ 0x1f250, 0x209d5, 0x216f0, 0x228ba, 0x2379f, 0x24b0b, 0x2542e, 0x26a64,
+ 0x27541, 0x28c69,
+}
+
+func main() {
+ fmt.Printf("\t{},\n")
+ for i := 1; i <= 40; i++ {
+ apos := align[i][0] - 2
+ if apos < 0 {
+ apos = 100
+ }
+ astride := align[i][1] - align[i][0]
+ if astride < 1 {
+ astride = 100
+ }
+ fmt.Printf("\t{%v, %v, %v, %#x, [4]level{{%v, %v}, {%v, %v}, {%v, %v}, {%v, %v}}}, // %v\n",
+ apos, astride, capacity[i].words,
+ versionPattern[i],
+ eccTable[i][0][0]+eccTable[i][0][1],
+ float64(capacity[i].ec[0])/float64(eccTable[i][0][0]+eccTable[i][0][1]),
+ eccTable[i][1][0]+eccTable[i][1][1],
+ float64(capacity[i].ec[1])/float64(eccTable[i][1][0]+eccTable[i][1][1]),
+ eccTable[i][2][0]+eccTable[i][2][1],
+ float64(capacity[i].ec[2])/float64(eccTable[i][2][0]+eccTable[i][2][1]),
+ eccTable[i][3][0]+eccTable[i][3][1],
+ float64(capacity[i].ec[3])/float64(eccTable[i][3][0]+eccTable[i][3][1]),
+ i,
+ )
+ }
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr.go
new file mode 100644
index 000000000..35711a4eb
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr.go
@@ -0,0 +1,815 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package coding implements low-level QR coding details.
+package coding
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/mattermost/rsc/gf256"
+)
+
+// Field is the field for QR error correction.
+var Field = gf256.NewField(0x11d, 2)
+
+// A Version represents a QR version.
+// The version specifies the size of the QR code:
+// a QR code with version v has 4v+17 pixels on a side.
+// Versions number from 1 to 40: the larger the version,
+// the more information the code can store.
+type Version int
+
+const MinVersion = 1
+const MaxVersion = 40
+
+func (v Version) String() string {
+ return strconv.Itoa(int(v))
+}
+
+func (v Version) sizeClass() int {
+ if v <= 9 {
+ return 0
+ }
+ if v <= 26 {
+ return 1
+ }
+ return 2
+}
+
+// DataBytes returns the number of data bytes that can be
+// stored in a QR code with the given version and level.
+func (v Version) DataBytes(l Level) int {
+ vt := &vtab[v]
+ lev := &vt.level[l]
+ return vt.bytes - lev.nblock*lev.check
+}
+
+// Encoding implements a QR data encoding scheme.
+// The implementations--Numeric, Alphanumeric, and String--specify
+// the character set and the mapping from UTF-8 to code bits.
+// The more restrictive the mode, the fewer code bits are needed.
+type Encoding interface {
+ Check() error
+ Bits(v Version) int
+ Encode(b *Bits, v Version)
+}
+
+type Bits struct {
+ b []byte
+ nbit int
+}
+
+func (b *Bits) Reset() {
+ b.b = b.b[:0]
+ b.nbit = 0
+}
+
+func (b *Bits) Bits() int {
+ return b.nbit
+}
+
+func (b *Bits) Bytes() []byte {
+ if b.nbit%8 != 0 {
+ panic("fractional byte")
+ }
+ return b.b
+}
+
+func (b *Bits) Append(p []byte) {
+ if b.nbit%8 != 0 {
+ panic("fractional byte")
+ }
+ b.b = append(b.b, p...)
+ b.nbit += 8 * len(p)
+}
+
+func (b *Bits) Write(v uint, nbit int) {
+ for nbit > 0 {
+ n := nbit
+ if n > 8 {
+ n = 8
+ }
+ if b.nbit%8 == 0 {
+ b.b = append(b.b, 0)
+ } else {
+ m := -b.nbit & 7
+ if n > m {
+ n = m
+ }
+ }
+ b.nbit += n
+ sh := uint(nbit - n)
+ b.b[len(b.b)-1] |= uint8(v >> sh << uint(-b.nbit&7))
+ v -= v >> sh << sh
+ nbit -= n
+ }
+}
+
+// Num is the encoding for numeric data.
+// The only valid characters are the decimal digits 0 through 9.
+type Num string
+
+func (s Num) String() string {
+ return fmt.Sprintf("Num(%#q)", string(s))
+}
+
+func (s Num) Check() error {
+ for _, c := range s {
+ if c < '0' || '9' < c {
+ return fmt.Errorf("non-numeric string %#q", string(s))
+ }
+ }
+ return nil
+}
+
+var numLen = [3]int{10, 12, 14}
+
+func (s Num) Bits(v Version) int {
+ return 4 + numLen[v.sizeClass()] + (10*len(s)+2)/3
+}
+
+func (s Num) Encode(b *Bits, v Version) {
+ b.Write(1, 4)
+ b.Write(uint(len(s)), numLen[v.sizeClass()])
+ var i int
+ for i = 0; i+3 <= len(s); i += 3 {
+ w := uint(s[i]-'0')*100 + uint(s[i+1]-'0')*10 + uint(s[i+2]-'0')
+ b.Write(w, 10)
+ }
+ switch len(s) - i {
+ case 1:
+ w := uint(s[i] - '0')
+ b.Write(w, 4)
+ case 2:
+ w := uint(s[i]-'0')*10 + uint(s[i+1]-'0')
+ b.Write(w, 7)
+ }
+}
+
+// Alpha is the encoding for alphanumeric data.
+// The valid characters are 0-9A-Z$%*+-./: and space.
+type Alpha string
+
+const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
+
+func (s Alpha) String() string {
+ return fmt.Sprintf("Alpha(%#q)", string(s))
+}
+
+func (s Alpha) Check() error {
+ for _, c := range s {
+ if strings.IndexRune(alphabet, c) < 0 {
+ return fmt.Errorf("non-alphanumeric string %#q", string(s))
+ }
+ }
+ return nil
+}
+
+var alphaLen = [3]int{9, 11, 13}
+
+func (s Alpha) Bits(v Version) int {
+ return 4 + alphaLen[v.sizeClass()] + (11*len(s)+1)/2
+}
+
+func (s Alpha) Encode(b *Bits, v Version) {
+ b.Write(2, 4)
+ b.Write(uint(len(s)), alphaLen[v.sizeClass()])
+ var i int
+ for i = 0; i+2 <= len(s); i += 2 {
+ w := uint(strings.IndexRune(alphabet, rune(s[i])))*45 +
+ uint(strings.IndexRune(alphabet, rune(s[i+1])))
+ b.Write(w, 11)
+ }
+
+ if i < len(s) {
+ w := uint(strings.IndexRune(alphabet, rune(s[i])))
+ b.Write(w, 6)
+ }
+}
+
+// String is the encoding for 8-bit data. All bytes are valid.
+type String string
+
+func (s String) String() string {
+ return fmt.Sprintf("String(%#q)", string(s))
+}
+
+func (s String) Check() error {
+ return nil
+}
+
+var stringLen = [3]int{8, 16, 16}
+
+func (s String) Bits(v Version) int {
+ return 4 + stringLen[v.sizeClass()] + 8*len(s)
+}
+
+func (s String) Encode(b *Bits, v Version) {
+ b.Write(4, 4)
+ b.Write(uint(len(s)), stringLen[v.sizeClass()])
+ for i := 0; i < len(s); i++ {
+ b.Write(uint(s[i]), 8)
+ }
+}
+
+// A Pixel describes a single pixel in a QR code.
+type Pixel uint32
+
+const (
+ Black Pixel = 1 << iota
+ Invert
+)
+
+func (p Pixel) Offset() uint {
+ return uint(p >> 6)
+}
+
+func OffsetPixel(o uint) Pixel {
+ return Pixel(o << 6)
+}
+
+func (r PixelRole) Pixel() Pixel {
+ return Pixel(r << 2)
+}
+
+func (p Pixel) Role() PixelRole {
+ return PixelRole(p>>2) & 15
+}
+
+func (p Pixel) String() string {
+ s := p.Role().String()
+ if p&Black != 0 {
+ s += "+black"
+ }
+ if p&Invert != 0 {
+ s += "+invert"
+ }
+ s += "+" + strconv.FormatUint(uint64(p.Offset()), 10)
+ return s
+}
+
+// A PixelRole describes the role of a QR pixel.
+type PixelRole uint32
+
+const (
+ _ PixelRole = iota
+ Position // position squares (large)
+ Alignment // alignment squares (small)
+ Timing // timing strip between position squares
+ Format // format metadata
+ PVersion // version pattern
+ Unused // unused pixel
+ Data // data bit
+ Check // error correction check bit
+ Extra
+)
+
+var roles = []string{
+ "",
+ "position",
+ "alignment",
+ "timing",
+ "format",
+ "pversion",
+ "unused",
+ "data",
+ "check",
+ "extra",
+}
+
+func (r PixelRole) String() string {
+ if Position <= r && r <= Check {
+ return roles[r]
+ }
+ return strconv.Itoa(int(r))
+}
+
+// A Level represents a QR error correction level.
+// From least to most tolerant of errors, they are L, M, Q, H.
+type Level int
+
+const (
+ L Level = iota
+ M
+ Q
+ H
+)
+
+func (l Level) String() string {
+ if L <= l && l <= H {
+ return "LMQH"[l : l+1]
+ }
+ return strconv.Itoa(int(l))
+}
+
+// A Code is a square pixel grid.
+type Code struct {
+ Bitmap []byte // 1 is black, 0 is white
+ Size int // number of pixels on a side
+ Stride int // number of bytes per row
+}
+
+func (c *Code) Black(x, y int) bool {
+ return 0 <= x && x < c.Size && 0 <= y && y < c.Size &&
+ c.Bitmap[y*c.Stride+x/8]&(1<<uint(7-x&7)) != 0
+}
+
+// A Mask describes a mask that is applied to the QR
+// code to avoid QR artifacts being interpreted as
+// alignment and timing patterns (such as the squares
+// in the corners). Valid masks are integers from 0 to 7.
+type Mask int
+
+// http://www.swetake.com/qr/qr5_en.html
+var mfunc = []func(int, int) bool{
+ func(i, j int) bool { return (i+j)%2 == 0 },
+ func(i, j int) bool { return i%2 == 0 },
+ func(i, j int) bool { return j%3 == 0 },
+ func(i, j int) bool { return (i+j)%3 == 0 },
+ func(i, j int) bool { return (i/2+j/3)%2 == 0 },
+ func(i, j int) bool { return i*j%2+i*j%3 == 0 },
+ func(i, j int) bool { return (i*j%2+i*j%3)%2 == 0 },
+ func(i, j int) bool { return (i*j%3+(i+j)%2)%2 == 0 },
+}
+
+func (m Mask) Invert(y, x int) bool {
+ if m < 0 {
+ return false
+ }
+ return mfunc[m](y, x)
+}
+
+// A Plan describes how to construct a QR code
+// with a specific version, level, and mask.
+type Plan struct {
+ Version Version
+ Level Level
+ Mask Mask
+
+ DataBytes int // number of data bytes
+ CheckBytes int // number of error correcting (checksum) bytes
+ Blocks int // number of data blocks
+
+ Pixel [][]Pixel // pixel map
+}
+
+// NewPlan returns a Plan for a QR code with the given
+// version, level, and mask.
+func NewPlan(version Version, level Level, mask Mask) (*Plan, error) {
+ p, err := vplan(version)
+ if err != nil {
+ return nil, err
+ }
+ if err := fplan(level, mask, p); err != nil {
+ return nil, err
+ }
+ if err := lplan(version, level, p); err != nil {
+ return nil, err
+ }
+ if err := mplan(mask, p); err != nil {
+ return nil, err
+ }
+ return p, nil
+}
+
+func (b *Bits) Pad(n int) {
+ if n < 0 {
+ panic("qr: invalid pad size")
+ }
+ if n <= 4 {
+ b.Write(0, n)
+ } else {
+ b.Write(0, 4)
+ n -= 4
+ n -= -b.Bits() & 7
+ b.Write(0, -b.Bits()&7)
+ pad := n / 8
+ for i := 0; i < pad; i += 2 {
+ b.Write(0xec, 8)
+ if i+1 >= pad {
+ break
+ }
+ b.Write(0x11, 8)
+ }
+ }
+}
+
+func (b *Bits) AddCheckBytes(v Version, l Level) {
+ nd := v.DataBytes(l)
+ if b.nbit < nd*8 {
+ b.Pad(nd*8 - b.nbit)
+ }
+ if b.nbit != nd*8 {
+ panic("qr: too much data")
+ }
+
+ dat := b.Bytes()
+ vt := &vtab[v]
+ lev := &vt.level[l]
+ db := nd / lev.nblock
+ extra := nd % lev.nblock
+ chk := make([]byte, lev.check)
+ rs := gf256.NewRSEncoder(Field, lev.check)
+ for i := 0; i < lev.nblock; i++ {
+ if i == lev.nblock-extra {
+ db++
+ }
+ rs.ECC(dat[:db], chk)
+ b.Append(chk)
+ dat = dat[db:]
+ }
+
+ if len(b.Bytes()) != vt.bytes {
+ panic("qr: internal error")
+ }
+}
+
+func (p *Plan) Encode(text ...Encoding) (*Code, error) {
+ var b Bits
+ for _, t := range text {
+ if err := t.Check(); err != nil {
+ return nil, err
+ }
+ t.Encode(&b, p.Version)
+ }
+ if b.Bits() > p.DataBytes*8 {
+ return nil, fmt.Errorf("cannot encode %d bits into %d-bit code", b.Bits(), p.DataBytes*8)
+ }
+ b.AddCheckBytes(p.Version, p.Level)
+ bytes := b.Bytes()
+
+ // Now we have the checksum bytes and the data bytes.
+ // Construct the actual code.
+ c := &Code{Size: len(p.Pixel), Stride: (len(p.Pixel) + 7) &^ 7}
+ c.Bitmap = make([]byte, c.Stride*c.Size)
+ crow := c.Bitmap
+ for _, row := range p.Pixel {
+ for x, pix := range row {
+ switch pix.Role() {
+ case Data, Check:
+ o := pix.Offset()
+ if bytes[o/8]&(1<<uint(7-o&7)) != 0 {
+ pix ^= Black
+ }
+ }
+ if pix&Black != 0 {
+ crow[x/8] |= 1 << uint(7-x&7)
+ }
+ }
+ crow = crow[c.Stride:]
+ }
+ return c, nil
+}
+
+// A version describes metadata associated with a version.
+type version struct {
+ apos int
+ astride int
+ bytes int
+ pattern int
+ level [4]level
+}
+
+type level struct {
+ nblock int
+ check int
+}
+
+var vtab = []version{
+ {},
+ {100, 100, 26, 0x0, [4]level{{1, 7}, {1, 10}, {1, 13}, {1, 17}}}, // 1
+ {16, 100, 44, 0x0, [4]level{{1, 10}, {1, 16}, {1, 22}, {1, 28}}}, // 2
+ {20, 100, 70, 0x0, [4]level{{1, 15}, {1, 26}, {2, 18}, {2, 22}}}, // 3
+ {24, 100, 100, 0x0, [4]level{{1, 20}, {2, 18}, {2, 26}, {4, 16}}}, // 4
+ {28, 100, 134, 0x0, [4]level{{1, 26}, {2, 24}, {4, 18}, {4, 22}}}, // 5
+ {32, 100, 172, 0x0, [4]level{{2, 18}, {4, 16}, {4, 24}, {4, 28}}}, // 6
+ {20, 16, 196, 0x7c94, [4]level{{2, 20}, {4, 18}, {6, 18}, {5, 26}}}, // 7
+ {22, 18, 242, 0x85bc, [4]level{{2, 24}, {4, 22}, {6, 22}, {6, 26}}}, // 8
+ {24, 20, 292, 0x9a99, [4]level{{2, 30}, {5, 22}, {8, 20}, {8, 24}}}, // 9
+ {26, 22, 346, 0xa4d3, [4]level{{4, 18}, {5, 26}, {8, 24}, {8, 28}}}, // 10
+ {28, 24, 404, 0xbbf6, [4]level{{4, 20}, {5, 30}, {8, 28}, {11, 24}}}, // 11
+ {30, 26, 466, 0xc762, [4]level{{4, 24}, {8, 22}, {10, 26}, {11, 28}}}, // 12
+ {32, 28, 532, 0xd847, [4]level{{4, 26}, {9, 22}, {12, 24}, {16, 22}}}, // 13
+ {24, 20, 581, 0xe60d, [4]level{{4, 30}, {9, 24}, {16, 20}, {16, 24}}}, // 14
+ {24, 22, 655, 0xf928, [4]level{{6, 22}, {10, 24}, {12, 30}, {18, 24}}}, // 15
+ {24, 24, 733, 0x10b78, [4]level{{6, 24}, {10, 28}, {17, 24}, {16, 30}}}, // 16
+ {28, 24, 815, 0x1145d, [4]level{{6, 28}, {11, 28}, {16, 28}, {19, 28}}}, // 17
+ {28, 26, 901, 0x12a17, [4]level{{6, 30}, {13, 26}, {18, 28}, {21, 28}}}, // 18
+ {28, 28, 991, 0x13532, [4]level{{7, 28}, {14, 26}, {21, 26}, {25, 26}}}, // 19
+ {32, 28, 1085, 0x149a6, [4]level{{8, 28}, {16, 26}, {20, 30}, {25, 28}}}, // 20
+ {26, 22, 1156, 0x15683, [4]level{{8, 28}, {17, 26}, {23, 28}, {25, 30}}}, // 21
+ {24, 24, 1258, 0x168c9, [4]level{{9, 28}, {17, 28}, {23, 30}, {34, 24}}}, // 22
+ {28, 24, 1364, 0x177ec, [4]level{{9, 30}, {18, 28}, {25, 30}, {30, 30}}}, // 23
+ {26, 26, 1474, 0x18ec4, [4]level{{10, 30}, {20, 28}, {27, 30}, {32, 30}}}, // 24
+ {30, 26, 1588, 0x191e1, [4]level{{12, 26}, {21, 28}, {29, 30}, {35, 30}}}, // 25
+ {28, 28, 1706, 0x1afab, [4]level{{12, 28}, {23, 28}, {34, 28}, {37, 30}}}, // 26
+ {32, 28, 1828, 0x1b08e, [4]level{{12, 30}, {25, 28}, {34, 30}, {40, 30}}}, // 27
+ {24, 24, 1921, 0x1cc1a, [4]level{{13, 30}, {26, 28}, {35, 30}, {42, 30}}}, // 28
+ {28, 24, 2051, 0x1d33f, [4]level{{14, 30}, {28, 28}, {38, 30}, {45, 30}}}, // 29
+ {24, 26, 2185, 0x1ed75, [4]level{{15, 30}, {29, 28}, {40, 30}, {48, 30}}}, // 30
+ {28, 26, 2323, 0x1f250, [4]level{{16, 30}, {31, 28}, {43, 30}, {51, 30}}}, // 31
+ {32, 26, 2465, 0x209d5, [4]level{{17, 30}, {33, 28}, {45, 30}, {54, 30}}}, // 32
+ {28, 28, 2611, 0x216f0, [4]level{{18, 30}, {35, 28}, {48, 30}, {57, 30}}}, // 33
+ {32, 28, 2761, 0x228ba, [4]level{{19, 30}, {37, 28}, {51, 30}, {60, 30}}}, // 34
+ {28, 24, 2876, 0x2379f, [4]level{{19, 30}, {38, 28}, {53, 30}, {63, 30}}}, // 35
+ {22, 26, 3034, 0x24b0b, [4]level{{20, 30}, {40, 28}, {56, 30}, {66, 30}}}, // 36
+ {26, 26, 3196, 0x2542e, [4]level{{21, 30}, {43, 28}, {59, 30}, {70, 30}}}, // 37
+ {30, 26, 3362, 0x26a64, [4]level{{22, 30}, {45, 28}, {62, 30}, {74, 30}}}, // 38
+ {24, 28, 3532, 0x27541, [4]level{{24, 30}, {47, 28}, {65, 30}, {77, 30}}}, // 39
+ {28, 28, 3706, 0x28c69, [4]level{{25, 30}, {49, 28}, {68, 30}, {81, 30}}}, // 40
+}
+
+func grid(siz int) [][]Pixel {
+ m := make([][]Pixel, siz)
+ pix := make([]Pixel, siz*siz)
+ for i := range m {
+ m[i], pix = pix[:siz], pix[siz:]
+ }
+ return m
+}
+
+// vplan creates a Plan for the given version.
+func vplan(v Version) (*Plan, error) {
+ p := &Plan{Version: v}
+ if v < 1 || v > 40 {
+ return nil, fmt.Errorf("invalid QR version %d", int(v))
+ }
+ siz := 17 + int(v)*4
+ m := grid(siz)
+ p.Pixel = m
+
+ // Timing markers (overwritten by boxes).
+ const ti = 6 // timing is in row/column 6 (counting from 0)
+ for i := range m {
+ p := Timing.Pixel()
+ if i&1 == 0 {
+ p |= Black
+ }
+ m[i][ti] = p
+ m[ti][i] = p
+ }
+
+ // Position boxes.
+ posBox(m, 0, 0)
+ posBox(m, siz-7, 0)
+ posBox(m, 0, siz-7)
+
+ // Alignment boxes.
+ info := &vtab[v]
+ for x := 4; x+5 < siz; {
+ for y := 4; y+5 < siz; {
+ // don't overwrite timing markers
+ if (x < 7 && y < 7) || (x < 7 && y+5 >= siz-7) || (x+5 >= siz-7 && y < 7) {
+ } else {
+ alignBox(m, x, y)
+ }
+ if y == 4 {
+ y = info.apos
+ } else {
+ y += info.astride
+ }
+ }
+ if x == 4 {
+ x = info.apos
+ } else {
+ x += info.astride
+ }
+ }
+
+ // Version pattern.
+ pat := vtab[v].pattern
+ if pat != 0 {
+ v := pat
+ for x := 0; x < 6; x++ {
+ for y := 0; y < 3; y++ {
+ p := PVersion.Pixel()
+ if v&1 != 0 {
+ p |= Black
+ }
+ m[siz-11+y][x] = p
+ m[x][siz-11+y] = p
+ v >>= 1
+ }
+ }
+ }
+
+ // One lonely black pixel
+ m[siz-8][8] = Unused.Pixel() | Black
+
+ return p, nil
+}
+
+// fplan adds the format pixels
+func fplan(l Level, m Mask, p *Plan) error {
+ // Format pixels.
+ fb := uint32(l^1) << 13 // level: L=01, M=00, Q=11, H=10
+ fb |= uint32(m) << 10 // mask
+ const formatPoly = 0x537
+ rem := fb
+ for i := 14; i >= 10; i-- {
+ if rem&(1<<uint(i)) != 0 {
+ rem ^= formatPoly << uint(i-10)
+ }
+ }
+ fb |= rem
+ invert := uint32(0x5412)
+ siz := len(p.Pixel)
+ for i := uint(0); i < 15; i++ {
+ pix := Format.Pixel() + OffsetPixel(i)
+ if (fb>>i)&1 == 1 {
+ pix |= Black
+ }
+ if (invert>>i)&1 == 1 {
+ pix ^= Invert | Black
+ }
+ // top left
+ switch {
+ case i < 6:
+ p.Pixel[i][8] = pix
+ case i < 8:
+ p.Pixel[i+1][8] = pix
+ case i < 9:
+ p.Pixel[8][7] = pix
+ default:
+ p.Pixel[8][14-i] = pix
+ }
+ // bottom right
+ switch {
+ case i < 8:
+ p.Pixel[8][siz-1-int(i)] = pix
+ default:
+ p.Pixel[siz-1-int(14-i)][8] = pix
+ }
+ }
+ return nil
+}
+
+// lplan edits a version-only Plan to add information
+// about the error correction levels.
+func lplan(v Version, l Level, p *Plan) error {
+ p.Level = l
+
+ nblock := vtab[v].level[l].nblock
+ ne := vtab[v].level[l].check
+ nde := (vtab[v].bytes - ne*nblock) / nblock
+ extra := (vtab[v].bytes - ne*nblock) % nblock
+ dataBits := (nde*nblock + extra) * 8
+ checkBits := ne * nblock * 8
+
+ p.DataBytes = vtab[v].bytes - ne*nblock
+ p.CheckBytes = ne * nblock
+ p.Blocks = nblock
+
+ // Make data + checksum pixels.
+ data := make([]Pixel, dataBits)
+ for i := range data {
+ data[i] = Data.Pixel() | OffsetPixel(uint(i))
+ }
+ check := make([]Pixel, checkBits)
+ for i := range check {
+ check[i] = Check.Pixel() | OffsetPixel(uint(i+dataBits))
+ }
+
+ // Split into blocks.
+ dataList := make([][]Pixel, nblock)
+ checkList := make([][]Pixel, nblock)
+ for i := 0; i < nblock; i++ {
+ // The last few blocks have an extra data byte (8 pixels).
+ nd := nde
+ if i >= nblock-extra {
+ nd++
+ }
+ dataList[i], data = data[0:nd*8], data[nd*8:]
+ checkList[i], check = check[0:ne*8], check[ne*8:]
+ }
+ if len(data) != 0 || len(check) != 0 {
+ panic("data/check math")
+ }
+
+ // Build up bit sequence, taking first byte of each block,
+ // then second byte, and so on. Then checksums.
+ bits := make([]Pixel, dataBits+checkBits)
+ dst := bits
+ for i := 0; i < nde+1; i++ {
+ for _, b := range dataList {
+ if i*8 < len(b) {
+ copy(dst, b[i*8:(i+1)*8])
+ dst = dst[8:]
+ }
+ }
+ }
+ for i := 0; i < ne; i++ {
+ for _, b := range checkList {
+ if i*8 < len(b) {
+ copy(dst, b[i*8:(i+1)*8])
+ dst = dst[8:]
+ }
+ }
+ }
+ if len(dst) != 0 {
+ panic("dst math")
+ }
+
+ // Sweep up pair of columns,
+ // then down, assigning to right then left pixel.
+ // Repeat.
+ // See Figure 2 of http://www.pclviewer.com/rs2/qrtopology.htm
+ siz := len(p.Pixel)
+ rem := make([]Pixel, 7)
+ for i := range rem {
+ rem[i] = Extra.Pixel()
+ }
+ src := append(bits, rem...)
+ for x := siz; x > 0; {
+ for y := siz - 1; y >= 0; y-- {
+ if p.Pixel[y][x-1].Role() == 0 {
+ p.Pixel[y][x-1], src = src[0], src[1:]
+ }
+ if p.Pixel[y][x-2].Role() == 0 {
+ p.Pixel[y][x-2], src = src[0], src[1:]
+ }
+ }
+ x -= 2
+ if x == 7 { // vertical timing strip
+ x--
+ }
+ for y := 0; y < siz; y++ {
+ if p.Pixel[y][x-1].Role() == 0 {
+ p.Pixel[y][x-1], src = src[0], src[1:]
+ }
+ if p.Pixel[y][x-2].Role() == 0 {
+ p.Pixel[y][x-2], src = src[0], src[1:]
+ }
+ }
+ x -= 2
+ }
+ return nil
+}
+
+// mplan edits a version+level-only Plan to add the mask.
+func mplan(m Mask, p *Plan) error {
+ p.Mask = m
+ for y, row := range p.Pixel {
+ for x, pix := range row {
+ if r := pix.Role(); (r == Data || r == Check || r == Extra) && p.Mask.Invert(y, x) {
+ row[x] ^= Black | Invert
+ }
+ }
+ }
+ return nil
+}
+
+// posBox draws a position (large) box at upper left x, y.
+func posBox(m [][]Pixel, x, y int) {
+ pos := Position.Pixel()
+ // box
+ for dy := 0; dy < 7; dy++ {
+ for dx := 0; dx < 7; dx++ {
+ p := pos
+ if dx == 0 || dx == 6 || dy == 0 || dy == 6 || 2 <= dx && dx <= 4 && 2 <= dy && dy <= 4 {
+ p |= Black
+ }
+ m[y+dy][x+dx] = p
+ }
+ }
+ // white border
+ for dy := -1; dy < 8; dy++ {
+ if 0 <= y+dy && y+dy < len(m) {
+ if x > 0 {
+ m[y+dy][x-1] = pos
+ }
+ if x+7 < len(m) {
+ m[y+dy][x+7] = pos
+ }
+ }
+ }
+ for dx := -1; dx < 8; dx++ {
+ if 0 <= x+dx && x+dx < len(m) {
+ if y > 0 {
+ m[y-1][x+dx] = pos
+ }
+ if y+7 < len(m) {
+ m[y+7][x+dx] = pos
+ }
+ }
+ }
+}
+
+// alignBox draw an alignment (small) box at upper left x, y.
+func alignBox(m [][]Pixel, x, y int) {
+ // box
+ align := Alignment.Pixel()
+ for dy := 0; dy < 5; dy++ {
+ for dx := 0; dx < 5; dx++ {
+ p := align
+ if dx == 0 || dx == 4 || dy == 0 || dy == 4 || dx == 2 && dy == 2 {
+ p |= Black
+ }
+ m[y+dy][x+dx] = p
+ }
+ }
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr_test.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr_test.go
new file mode 100644
index 000000000..b8199bb51
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/coding/qr_test.go
@@ -0,0 +1,133 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package coding
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/mattermost/rsc/gf256"
+ "github.com/mattermost/rsc/qr/libqrencode"
+)
+
+func test(t *testing.T, v Version, l Level, text ...Encoding) bool {
+ s := ""
+ ty := libqrencode.EightBit
+ switch x := text[0].(type) {
+ case String:
+ s = string(x)
+ case Alpha:
+ s = string(x)
+ ty = libqrencode.Alphanumeric
+ case Num:
+ s = string(x)
+ ty = libqrencode.Numeric
+ }
+ key, err := libqrencode.Encode(libqrencode.Version(v), libqrencode.Level(l), ty, s)
+ if err != nil {
+ t.Errorf("libqrencode.Encode(%v, %v, %d, %#q): %v", v, l, ty, s, err)
+ return false
+ }
+ mask := (^key.Pixel[8][2]&1)<<2 | (key.Pixel[8][3]&1)<<1 | (^key.Pixel[8][4] & 1)
+ p, err := NewPlan(v, l, Mask(mask))
+ if err != nil {
+ t.Errorf("NewPlan(%v, L, %d): %v", v, err, mask)
+ return false
+ }
+ if len(p.Pixel) != len(key.Pixel) {
+ t.Errorf("%v: NewPlan uses %dx%d, libqrencode uses %dx%d", v, len(p.Pixel), len(p.Pixel), len(key.Pixel), len(key.Pixel))
+ return false
+ }
+ c, err := p.Encode(text...)
+ if err != nil {
+ t.Errorf("Encode: %v", err)
+ return false
+ }
+ badpix := 0
+Pixel:
+ for y, prow := range p.Pixel {
+ for x, pix := range prow {
+ pix &^= Black
+ if c.Black(x, y) {
+ pix |= Black
+ }
+
+ keypix := key.Pixel[y][x]
+ want := Pixel(0)
+ switch {
+ case keypix&libqrencode.Finder != 0:
+ want = Position.Pixel()
+ case keypix&libqrencode.Alignment != 0:
+ want = Alignment.Pixel()
+ case keypix&libqrencode.Timing != 0:
+ want = Timing.Pixel()
+ case keypix&libqrencode.Format != 0:
+ want = Format.Pixel()
+ want |= OffsetPixel(pix.Offset()) // sic
+ want |= pix & Invert
+ case keypix&libqrencode.PVersion != 0:
+ want = PVersion.Pixel()
+ case keypix&libqrencode.DataECC != 0:
+ if pix.Role() == Check || pix.Role() == Extra {
+ want = pix.Role().Pixel()
+ } else {
+ want = Data.Pixel()
+ }
+ want |= OffsetPixel(pix.Offset())
+ want |= pix & Invert
+ default:
+ want = Unused.Pixel()
+ }
+ if keypix&libqrencode.Black != 0 {
+ want |= Black
+ }
+ if pix != want {
+ t.Errorf("%v/%v: Pixel[%d][%d] = %v, want %v %#x", v, mask, y, x, pix, want, keypix)
+ if badpix++; badpix >= 100 {
+ t.Errorf("stopping after %d bad pixels", badpix)
+ break Pixel
+ }
+ }
+ }
+ }
+ return badpix == 0
+}
+
+var input = []Encoding{
+ String("hello"),
+ Num("1"),
+ Num("12"),
+ Num("123"),
+ Alpha("AB"),
+ Alpha("ABC"),
+}
+
+func TestVersion(t *testing.T) {
+ badvers := 0
+Version:
+ for v := Version(1); v <= 40; v++ {
+ for l := L; l <= H; l++ {
+ for _, in := range input {
+ if !test(t, v, l, in) {
+ if badvers++; badvers >= 10 {
+ t.Errorf("stopping after %d bad versions", badvers)
+ break Version
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestEncode(t *testing.T) {
+ data := []byte{0x10, 0x20, 0x0c, 0x56, 0x61, 0x80, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11, 0xec, 0x11}
+ check := []byte{0xa5, 0x24, 0xd4, 0xc1, 0xed, 0x36, 0xc7, 0x87, 0x2c, 0x55}
+ rs := gf256.NewRSEncoder(Field, len(check))
+ out := make([]byte, len(check))
+ rs.ECC(data, out)
+ if !bytes.Equal(out, check) {
+ t.Errorf("have %x want %x", out, check)
+ }
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/Makefile b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/Makefile
new file mode 100644
index 000000000..4c9591462
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/Makefile
@@ -0,0 +1,4 @@
+include $(GOROOT)/src/Make.inc
+TARG=rsc.googlecode.com/hg/qr/libqrencode
+CGOFILES=qrencode.go
+include $(GOROOT)/src/Make.pkg
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/qrencode.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/qrencode.go
new file mode 100644
index 000000000..f4ce3ffb6
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/libqrencode/qrencode.go
@@ -0,0 +1,149 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package libqrencode wraps the C libqrencode library.
+// The qr package (in this package's parent directory)
+// does not use any C wrapping. This code is here only
+// for use during that package's tests.
+package libqrencode
+
+/*
+#cgo LDFLAGS: -lqrencode
+#include <qrencode.h>
+*/
+import "C"
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "unsafe"
+)
+
+type Version int
+
+type Mode int
+
+const (
+ Numeric Mode = C.QR_MODE_NUM
+ Alphanumeric Mode = C.QR_MODE_AN
+ EightBit Mode = C.QR_MODE_8
+)
+
+type Level int
+
+const (
+ L Level = C.QR_ECLEVEL_L
+ M Level = C.QR_ECLEVEL_M
+ Q Level = C.QR_ECLEVEL_Q
+ H Level = C.QR_ECLEVEL_H
+)
+
+type Pixel int
+
+const (
+ Black Pixel = 1 << iota
+ DataECC
+ Format
+ PVersion
+ Timing
+ Alignment
+ Finder
+ NonData
+)
+
+type Code struct {
+ Version int
+ Width int
+ Pixel [][]Pixel
+ Scale int
+}
+
+func (*Code) ColorModel() color.Model {
+ return color.RGBAModel
+}
+
+func (c *Code) Bounds() image.Rectangle {
+ d := (c.Width + 8) * c.Scale
+ return image.Rect(0, 0, d, d)
+}
+
+var (
+ white color.Color = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
+ black color.Color = color.RGBA{0x00, 0x00, 0x00, 0xFF}
+ blue color.Color = color.RGBA{0x00, 0x00, 0x80, 0xFF}
+ red color.Color = color.RGBA{0xFF, 0x40, 0x40, 0xFF}
+ yellow color.Color = color.RGBA{0xFF, 0xFF, 0x00, 0xFF}
+ gray color.Color = color.RGBA{0x80, 0x80, 0x80, 0xFF}
+ green color.Color = color.RGBA{0x22, 0x8B, 0x22, 0xFF}
+)
+
+func (c *Code) At(x, y int) color.Color {
+ x = x/c.Scale - 4
+ y = y/c.Scale - 4
+ if 0 <= x && x < c.Width && 0 <= y && y < c.Width {
+ switch p := c.Pixel[y][x]; {
+ case p&Black == 0:
+ // nothing
+ case p&DataECC != 0:
+ return black
+ case p&Format != 0:
+ return blue
+ case p&PVersion != 0:
+ return red
+ case p&Timing != 0:
+ return yellow
+ case p&Alignment != 0:
+ return gray
+ case p&Finder != 0:
+ return green
+ }
+ }
+ return white
+}
+
+type Chunk struct {
+ Mode Mode
+ Text string
+}
+
+func Encode(version Version, level Level, mode Mode, text string) (*Code, error) {
+ return EncodeChunk(version, level, Chunk{mode, text})
+}
+
+func EncodeChunk(version Version, level Level, chunk ...Chunk) (*Code, error) {
+ qi, err := C.QRinput_new2(C.int(version), C.QRecLevel(level))
+ if qi == nil {
+ return nil, fmt.Errorf("QRinput_new2: %v", err)
+ }
+ defer C.QRinput_free(qi)
+ for _, ch := range chunk {
+ data := []byte(ch.Text)
+ n, err := C.QRinput_append(qi, C.QRencodeMode(ch.Mode), C.int(len(data)), (*C.uchar)(&data[0]))
+ if n < 0 {
+ return nil, fmt.Errorf("QRinput_append %q: %v", data, err)
+ }
+ }
+
+ qc, err := C.QRcode_encodeInput(qi)
+ if qc == nil {
+ return nil, fmt.Errorf("QRinput_encodeInput: %v", err)
+ }
+
+ c := &Code{
+ Version: int(qc.version),
+ Width: int(qc.width),
+ Scale: 16,
+ }
+ pix := make([]Pixel, c.Width*c.Width)
+ cdat := (*[1000 * 1000]byte)(unsafe.Pointer(qc.data))[:len(pix)]
+ for i := range pix {
+ pix[i] = Pixel(cdat[i])
+ }
+ c.Pixel = make([][]Pixel, c.Width)
+ for i := range c.Pixel {
+ c.Pixel[i] = pix[i*c.Width : (i+1)*c.Width]
+ }
+ return c, nil
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/png.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/png.go
new file mode 100644
index 000000000..db49d0577
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/png.go
@@ -0,0 +1,400 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package qr
+
+// PNG writer for QR codes.
+
+import (
+ "bytes"
+ "encoding/binary"
+ "hash"
+ "hash/crc32"
+)
+
+// PNG returns a PNG image displaying the code.
+//
+// PNG uses a custom encoder tailored to QR codes.
+// Its compressed size is about 2x away from optimal,
+// but it runs about 20x faster than calling png.Encode
+// on c.Image().
+func (c *Code) PNG() []byte {
+ var p pngWriter
+ return p.encode(c)
+}
+
+type pngWriter struct {
+ tmp [16]byte
+ wctmp [4]byte
+ buf bytes.Buffer
+ zlib bitWriter
+ crc hash.Hash32
+}
+
+var pngHeader = []byte("\x89PNG\r\n\x1a\n")
+
+func (w *pngWriter) encode(c *Code) []byte {
+ scale := c.Scale
+ siz := c.Size
+
+ w.buf.Reset()
+
+ // Header
+ w.buf.Write(pngHeader)
+
+ // Header block
+ binary.BigEndian.PutUint32(w.tmp[0:4], uint32((siz+8)*scale))
+ binary.BigEndian.PutUint32(w.tmp[4:8], uint32((siz+8)*scale))
+ w.tmp[8] = 1 // 1-bit
+ w.tmp[9] = 0 // gray
+ w.tmp[10] = 0
+ w.tmp[11] = 0
+ w.tmp[12] = 0
+ w.writeChunk("IHDR", w.tmp[:13])
+
+ // Comment
+ w.writeChunk("tEXt", comment)
+
+ // Data
+ w.zlib.writeCode(c)
+ w.writeChunk("IDAT", w.zlib.bytes.Bytes())
+
+ // End
+ w.writeChunk("IEND", nil)
+
+ return w.buf.Bytes()
+}
+
+var comment = []byte("Software\x00QR-PNG http://qr.swtch.com/")
+
+func (w *pngWriter) writeChunk(name string, data []byte) {
+ if w.crc == nil {
+ w.crc = crc32.NewIEEE()
+ }
+ binary.BigEndian.PutUint32(w.wctmp[0:4], uint32(len(data)))
+ w.buf.Write(w.wctmp[0:4])
+ w.crc.Reset()
+ copy(w.wctmp[0:4], name)
+ w.buf.Write(w.wctmp[0:4])
+ w.crc.Write(w.wctmp[0:4])
+ w.buf.Write(data)
+ w.crc.Write(data)
+ crc := w.crc.Sum32()
+ binary.BigEndian.PutUint32(w.wctmp[0:4], crc)
+ w.buf.Write(w.wctmp[0:4])
+}
+
+func (b *bitWriter) writeCode(c *Code) {
+ const ftNone = 0
+
+ b.adler32.Reset()
+ b.bytes.Reset()
+ b.nbit = 0
+
+ scale := c.Scale
+ siz := c.Size
+
+ // zlib header
+ b.tmp[0] = 0x78
+ b.tmp[1] = 0
+ b.tmp[1] += uint8(31 - (uint16(b.tmp[0])<<8+uint16(b.tmp[1]))%31)
+ b.bytes.Write(b.tmp[0:2])
+
+ // Start flate block.
+ b.writeBits(1, 1, false) // final block
+ b.writeBits(1, 2, false) // compressed, fixed Huffman tables
+
+ // White border.
+ // First row.
+ b.byte(ftNone)
+ n := (scale*(siz+8) + 7) / 8
+ b.byte(255)
+ b.repeat(n-1, 1)
+ // 4*scale rows total.
+ b.repeat((4*scale-1)*(1+n), 1+n)
+
+ for i := 0; i < 4*scale; i++ {
+ b.adler32.WriteNByte(ftNone, 1)
+ b.adler32.WriteNByte(255, n)
+ }
+
+ row := make([]byte, 1+n)
+ for y := 0; y < siz; y++ {
+ row[0] = ftNone
+ j := 1
+ var z uint8
+ nz := 0
+ for x := -4; x < siz+4; x++ {
+ // Raw data.
+ for i := 0; i < scale; i++ {
+ z <<= 1
+ if !c.Black(x, y) {
+ z |= 1
+ }
+ if nz++; nz == 8 {
+ row[j] = z
+ j++
+ nz = 0
+ }
+ }
+ }
+ if j < len(row) {
+ row[j] = z
+ }
+ for _, z := range row {
+ b.byte(z)
+ }
+
+ // Scale-1 copies.
+ b.repeat((scale-1)*(1+n), 1+n)
+
+ b.adler32.WriteN(row, scale)
+ }
+
+ // White border.
+ // First row.
+ b.byte(ftNone)
+ b.byte(255)
+ b.repeat(n-1, 1)
+ // 4*scale rows total.
+ b.repeat((4*scale-1)*(1+n), 1+n)
+
+ for i := 0; i < 4*scale; i++ {
+ b.adler32.WriteNByte(ftNone, 1)
+ b.adler32.WriteNByte(255, n)
+ }
+
+ // End of block.
+ b.hcode(256)
+ b.flushBits()
+
+ // adler32
+ binary.BigEndian.PutUint32(b.tmp[0:], b.adler32.Sum32())
+ b.bytes.Write(b.tmp[0:4])
+}
+
+// A bitWriter is a write buffer for bit-oriented data like deflate.
+type bitWriter struct {
+ bytes bytes.Buffer
+ bit uint32
+ nbit uint
+
+ tmp [4]byte
+ adler32 adigest
+}
+
+func (b *bitWriter) writeBits(bit uint32, nbit uint, rev bool) {
+ // reverse, for huffman codes
+ if rev {
+ br := uint32(0)
+ for i := uint(0); i < nbit; i++ {
+ br |= ((bit >> i) & 1) << (nbit - 1 - i)
+ }
+ bit = br
+ }
+ b.bit |= bit << b.nbit
+ b.nbit += nbit
+ for b.nbit >= 8 {
+ b.bytes.WriteByte(byte(b.bit))
+ b.bit >>= 8
+ b.nbit -= 8
+ }
+}
+
+func (b *bitWriter) flushBits() {
+ if b.nbit > 0 {
+ b.bytes.WriteByte(byte(b.bit))
+ b.nbit = 0
+ b.bit = 0
+ }
+}
+
+func (b *bitWriter) hcode(v int) {
+ /*
+ Lit Value Bits Codes
+ --------- ---- -----
+ 0 - 143 8 00110000 through
+ 10111111
+ 144 - 255 9 110010000 through
+ 111111111
+ 256 - 279 7 0000000 through
+ 0010111
+ 280 - 287 8 11000000 through
+ 11000111
+ */
+ switch {
+ case v <= 143:
+ b.writeBits(uint32(v)+0x30, 8, true)
+ case v <= 255:
+ b.writeBits(uint32(v-144)+0x190, 9, true)
+ case v <= 279:
+ b.writeBits(uint32(v-256)+0, 7, true)
+ case v <= 287:
+ b.writeBits(uint32(v-280)+0xc0, 8, true)
+ default:
+ panic("invalid hcode")
+ }
+}
+
+func (b *bitWriter) byte(x byte) {
+ b.hcode(int(x))
+}
+
+func (b *bitWriter) codex(c int, val int, nx uint) {
+ b.hcode(c + val>>nx)
+ b.writeBits(uint32(val)&(1<<nx-1), nx, false)
+}
+
+func (b *bitWriter) repeat(n, d int) {
+ for ; n >= 258+3; n -= 258 {
+ b.repeat1(258, d)
+ }
+ if n > 258 {
+ // 258 < n < 258+3
+ b.repeat1(10, d)
+ b.repeat1(n-10, d)
+ return
+ }
+ if n < 3 {
+ panic("invalid flate repeat")
+ }
+ b.repeat1(n, d)
+}
+
+func (b *bitWriter) repeat1(n, d int) {
+ /*
+ Extra Extra Extra
+ Code Bits Length(s) Code Bits Lengths Code Bits Length(s)
+ ---- ---- ------ ---- ---- ------- ---- ---- -------
+ 257 0 3 267 1 15,16 277 4 67-82
+ 258 0 4 268 1 17,18 278 4 83-98
+ 259 0 5 269 2 19-22 279 4 99-114
+ 260 0 6 270 2 23-26 280 4 115-130
+ 261 0 7 271 2 27-30 281 5 131-162
+ 262 0 8 272 2 31-34 282 5 163-194
+ 263 0 9 273 3 35-42 283 5 195-226
+ 264 0 10 274 3 43-50 284 5 227-257
+ 265 1 11,12 275 3 51-58 285 0 258
+ 266 1 13,14 276 3 59-66
+ */
+ switch {
+ case n <= 10:
+ b.codex(257, n-3, 0)
+ case n <= 18:
+ b.codex(265, n-11, 1)
+ case n <= 34:
+ b.codex(269, n-19, 2)
+ case n <= 66:
+ b.codex(273, n-35, 3)
+ case n <= 130:
+ b.codex(277, n-67, 4)
+ case n <= 257:
+ b.codex(281, n-131, 5)
+ case n == 258:
+ b.hcode(285)
+ default:
+ panic("invalid repeat length")
+ }
+
+ /*
+ Extra Extra Extra
+ Code Bits Dist Code Bits Dist Code Bits Distance
+ ---- ---- ---- ---- ---- ------ ---- ---- --------
+ 0 0 1 10 4 33-48 20 9 1025-1536
+ 1 0 2 11 4 49-64 21 9 1537-2048
+ 2 0 3 12 5 65-96 22 10 2049-3072
+ 3 0 4 13 5 97-128 23 10 3073-4096
+ 4 1 5,6 14 6 129-192 24 11 4097-6144
+ 5 1 7,8 15 6 193-256 25 11 6145-8192
+ 6 2 9-12 16 7 257-384 26 12 8193-12288
+ 7 2 13-16 17 7 385-512 27 12 12289-16384
+ 8 3 17-24 18 8 513-768 28 13 16385-24576
+ 9 3 25-32 19 8 769-1024 29 13 24577-32768
+ */
+ if d <= 4 {
+ b.writeBits(uint32(d-1), 5, true)
+ } else if d <= 32768 {
+ nbit := uint(16)
+ for d <= 1<<(nbit-1) {
+ nbit--
+ }
+ v := uint32(d - 1)
+ v &^= 1 << (nbit - 1) // top bit is implicit
+ code := uint32(2*nbit - 2) // second bit is low bit of code
+ code |= v >> (nbit - 2)
+ v &^= 1 << (nbit - 2)
+ b.writeBits(code, 5, true)
+ // rest of bits follow
+ b.writeBits(uint32(v), nbit-2, false)
+ } else {
+ panic("invalid repeat distance")
+ }
+}
+
+func (b *bitWriter) run(v byte, n int) {
+ if n == 0 {
+ return
+ }
+ b.byte(v)
+ if n-1 < 3 {
+ for i := 0; i < n-1; i++ {
+ b.byte(v)
+ }
+ } else {
+ b.repeat(n-1, 1)
+ }
+}
+
+type adigest struct {
+ a, b uint32
+}
+
+func (d *adigest) Reset() { d.a, d.b = 1, 0 }
+
+const amod = 65521
+
+func aupdate(a, b uint32, pi byte, n int) (aa, bb uint32) {
+ // TODO(rsc): 6g doesn't do magic multiplies for b %= amod,
+ // only for b = b%amod.
+
+ // invariant: a, b < amod
+ if pi == 0 {
+ b += uint32(n%amod) * a
+ b = b % amod
+ return a, b
+ }
+
+ // n times:
+ // a += pi
+ // b += a
+ // is same as
+ // b += n*a + n*(n+1)/2*pi
+ // a += n*pi
+ m := uint32(n)
+ b += (m % amod) * a
+ b = b % amod
+ b += (m * (m + 1) / 2) % amod * uint32(pi)
+ b = b % amod
+ a += (m % amod) * uint32(pi)
+ a = a % amod
+ return a, b
+}
+
+func afinish(a, b uint32) uint32 {
+ return b<<16 | a
+}
+
+func (d *adigest) WriteN(p []byte, n int) {
+ for i := 0; i < n; i++ {
+ for _, pi := range p {
+ d.a, d.b = aupdate(d.a, d.b, pi, 1)
+ }
+ }
+}
+
+func (d *adigest) WriteNByte(pi byte, n int) {
+ d.a, d.b = aupdate(d.a, d.b, pi, n)
+}
+
+func (d *adigest) Sum32() uint32 { return afinish(d.a, d.b) }
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/png_test.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/png_test.go
new file mode 100644
index 000000000..27a622924
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/png_test.go
@@ -0,0 +1,73 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package qr
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/png"
+ "io/ioutil"
+ "testing"
+)
+
+func TestPNG(t *testing.T) {
+ c, err := Encode("hello, world", L)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pngdat := c.PNG()
+ if true {
+ ioutil.WriteFile("x.png", pngdat, 0666)
+ }
+ m, err := png.Decode(bytes.NewBuffer(pngdat))
+ if err != nil {
+ t.Fatal(err)
+ }
+ gm := m.(*image.Gray)
+
+ scale := c.Scale
+ siz := c.Size
+ nbad := 0
+ for y := 0; y < scale*(8+siz); y++ {
+ for x := 0; x < scale*(8+siz); x++ {
+ v := byte(255)
+ if c.Black(x/scale-4, y/scale-4) {
+ v = 0
+ }
+ if gv := gm.At(x, y).(color.Gray).Y; gv != v {
+ t.Errorf("%d,%d = %d, want %d", x, y, gv, v)
+ if nbad++; nbad >= 20 {
+ t.Fatalf("too many bad pixels")
+ }
+ }
+ }
+ }
+}
+
+func BenchmarkPNG(b *testing.B) {
+ c, err := Encode("0123456789012345678901234567890123456789", L)
+ if err != nil {
+ panic(err)
+ }
+ var bytes []byte
+ for i := 0; i < b.N; i++ {
+ bytes = c.PNG()
+ }
+ b.SetBytes(int64(len(bytes)))
+}
+
+func BenchmarkImagePNG(b *testing.B) {
+ c, err := Encode("0123456789012345678901234567890123456789", L)
+ if err != nil {
+ panic(err)
+ }
+ var buf bytes.Buffer
+ for i := 0; i < b.N; i++ {
+ buf.Reset()
+ png.Encode(&buf, c.Image())
+ }
+ b.SetBytes(int64(buf.Len()))
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/qr.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/qr.go
new file mode 100644
index 000000000..1d20d02f3
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/qr.go
@@ -0,0 +1,116 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+Package qr encodes QR codes.
+*/
+package qr
+
+import (
+ "errors"
+ "image"
+ "image/color"
+
+ "github.com/mattermost/rsc/qr/coding"
+)
+
+// A Level denotes a QR error correction level.
+// From least to most tolerant of errors, they are L, M, Q, H.
+type Level int
+
+const (
+ L Level = iota // 20% redundant
+ M // 38% redundant
+ Q // 55% redundant
+ H // 65% redundant
+)
+
+// Encode returns an encoding of text at the given error correction level.
+func Encode(text string, level Level) (*Code, error) {
+ // Pick data encoding, smallest first.
+ // We could split the string and use different encodings
+ // but that seems like overkill for now.
+ var enc coding.Encoding
+ switch {
+ case coding.Num(text).Check() == nil:
+ enc = coding.Num(text)
+ case coding.Alpha(text).Check() == nil:
+ enc = coding.Alpha(text)
+ default:
+ enc = coding.String(text)
+ }
+
+ // Pick size.
+ l := coding.Level(level)
+ var v coding.Version
+ for v = coding.MinVersion; ; v++ {
+ if v > coding.MaxVersion {
+ return nil, errors.New("text too long to encode as QR")
+ }
+ if enc.Bits(v) <= v.DataBytes(l)*8 {
+ break
+ }
+ }
+
+ // Build and execute plan.
+ p, err := coding.NewPlan(v, l, 0)
+ if err != nil {
+ return nil, err
+ }
+ cc, err := p.Encode(enc)
+ if err != nil {
+ return nil, err
+ }
+
+ // TODO: Pick appropriate mask.
+
+ return &Code{cc.Bitmap, cc.Size, cc.Stride, 8}, nil
+}
+
+// A Code is a square pixel grid.
+// It implements image.Image and direct PNG encoding.
+type Code struct {
+ Bitmap []byte // 1 is black, 0 is white
+ Size int // number of pixels on a side
+ Stride int // number of bytes per row
+ Scale int // number of image pixels per QR pixel
+}
+
+// Black returns true if the pixel at (x,y) is black.
+func (c *Code) Black(x, y int) bool {
+ return 0 <= x && x < c.Size && 0 <= y && y < c.Size &&
+ c.Bitmap[y*c.Stride+x/8]&(1<<uint(7-x&7)) != 0
+}
+
+// Image returns an Image displaying the code.
+func (c *Code) Image() image.Image {
+ return &codeImage{c}
+
+}
+
+// codeImage implements image.Image
+type codeImage struct {
+ *Code
+}
+
+var (
+ whiteColor color.Color = color.Gray{0xFF}
+ blackColor color.Color = color.Gray{0x00}
+)
+
+func (c *codeImage) Bounds() image.Rectangle {
+ d := (c.Size + 8) * c.Scale
+ return image.Rect(0, 0, d, d)
+}
+
+func (c *codeImage) At(x, y int) color.Color {
+ if c.Black(x, y) {
+ return blackColor
+ }
+ return whiteColor
+}
+
+func (c *codeImage) ColorModel() color.Model {
+ return color.GrayModel
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/pic.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/pic.go
new file mode 100644
index 000000000..6baef94d2
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/pic.go
@@ -0,0 +1,506 @@
+package web
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "code.google.com/p/freetype-go/freetype"
+ "github.com/mattermost/rsc/appfs/fs"
+ "github.com/mattermost/rsc/qr"
+ "github.com/mattermost/rsc/qr/coding"
+)
+
+func makeImage(req *http.Request, caption, font string, pt, size, border, scale int, f func(x, y int) uint32) *image.RGBA {
+ d := (size + 2*border) * scale
+ csize := 0
+ if caption != "" {
+ if pt == 0 {
+ pt = 11
+ }
+ csize = pt * 2
+ }
+ c := image.NewRGBA(image.Rect(0, 0, d, d+csize))
+
+ // white
+ u := &image.Uniform{C: color.White}
+ draw.Draw(c, c.Bounds(), u, image.ZP, draw.Src)
+
+ for y := 0; y < size; y++ {
+ for x := 0; x < size; x++ {
+ r := image.Rect((x+border)*scale, (y+border)*scale, (x+border+1)*scale, (y+border+1)*scale)
+ rgba := f(x, y)
+ u.C = color.RGBA{byte(rgba >> 24), byte(rgba >> 16), byte(rgba >> 8), byte(rgba)}
+ draw.Draw(c, r, u, image.ZP, draw.Src)
+ }
+ }
+
+ if csize != 0 {
+ if font == "" {
+ font = "data/luxisr.ttf"
+ }
+ ctxt := fs.NewContext(req)
+ dat, _, err := ctxt.Read(font)
+ if err != nil {
+ panic(err)
+ }
+ tfont, err := freetype.ParseFont(dat)
+ if err != nil {
+ panic(err)
+ }
+ ft := freetype.NewContext()
+ ft.SetDst(c)
+ ft.SetDPI(100)
+ ft.SetFont(tfont)
+ ft.SetFontSize(float64(pt))
+ ft.SetSrc(image.NewUniform(color.Black))
+ ft.SetClip(image.Rect(0, 0, 0, 0))
+ wid, err := ft.DrawString(caption, freetype.Pt(0, 0))
+ if err != nil {
+ panic(err)
+ }
+ p := freetype.Pt(d, d+3*pt/2)
+ p.X -= wid.X
+ p.X /= 2
+ ft.SetClip(c.Bounds())
+ ft.DrawString(caption, p)
+ }
+
+ return c
+}
+
+func makeFrame(req *http.Request, font string, pt, vers, l, scale, dots int) image.Image {
+ lev := coding.Level(l)
+ p, err := coding.NewPlan(coding.Version(vers), lev, 0)
+ if err != nil {
+ panic(err)
+ }
+
+ nd := p.DataBytes / p.Blocks
+ nc := p.CheckBytes / p.Blocks
+ extra := p.DataBytes - nd*p.Blocks
+
+ cap := fmt.Sprintf("QR v%d, %s", vers, lev)
+ if dots > 0 {
+ cap = fmt.Sprintf("QR v%d order, from bottom right", vers)
+ }
+ m := makeImage(req, cap, font, pt, len(p.Pixel), 0, scale, func(x, y int) uint32 {
+ pix := p.Pixel[y][x]
+ switch pix.Role() {
+ case coding.Data:
+ if dots > 0 {
+ return 0xffffffff
+ }
+ off := int(pix.Offset() / 8)
+ nd := nd
+ var i int
+ for i = 0; i < p.Blocks; i++ {
+ if i == extra {
+ nd++
+ }
+ if off < nd {
+ break
+ }
+ off -= nd
+ }
+ return blockColors[i%len(blockColors)]
+ case coding.Check:
+ if dots > 0 {
+ return 0xffffffff
+ }
+ i := (int(pix.Offset()/8) - p.DataBytes) / nc
+ return dark(blockColors[i%len(blockColors)])
+ }
+ if pix&coding.Black != 0 {
+ return 0x000000ff
+ }
+ return 0xffffffff
+ })
+
+ if dots > 0 {
+ b := m.Bounds()
+ for y := 0; y <= len(p.Pixel); y++ {
+ for x := 0; x < b.Dx(); x++ {
+ m.SetRGBA(x, y*scale-(y/len(p.Pixel)), color.RGBA{127, 127, 127, 255})
+ }
+ }
+ for x := 0; x <= len(p.Pixel); x++ {
+ for y := 0; y < b.Dx(); y++ {
+ m.SetRGBA(x*scale-(x/len(p.Pixel)), y, color.RGBA{127, 127, 127, 255})
+ }
+ }
+ order := make([]image.Point, (p.DataBytes+p.CheckBytes)*8+1)
+ for y, row := range p.Pixel {
+ for x, pix := range row {
+ if r := pix.Role(); r != coding.Data && r != coding.Check {
+ continue
+ }
+ // draw.Draw(m, m.Bounds().Add(image.Pt(x*scale, y*scale)), dot, image.ZP, draw.Over)
+ order[pix.Offset()] = image.Point{x*scale + scale/2, y*scale + scale/2}
+ }
+ }
+
+ for mode := 0; mode < 2; mode++ {
+ for i, p := range order {
+ q := order[i+1]
+ if q.X == 0 {
+ break
+ }
+ line(m, p, q, mode)
+ }
+ }
+ }
+ return m
+}
+
+func line(m *image.RGBA, p, q image.Point, mode int) {
+ x := 0
+ y := 0
+ dx := q.X - p.X
+ dy := q.Y - p.Y
+ xsign := +1
+ ysign := +1
+ if dx < 0 {
+ xsign = -1
+ dx = -dx
+ }
+ if dy < 0 {
+ ysign = -1
+ dy = -dy
+ }
+ pt := func() {
+ switch mode {
+ case 0:
+ for dx := -2; dx <= 2; dx++ {
+ for dy := -2; dy <= 2; dy++ {
+ if dy*dx <= -4 || dy*dx >= 4 {
+ continue
+ }
+ m.SetRGBA(p.X+x*xsign+dx, p.Y+y*ysign+dy, color.RGBA{255, 192, 192, 255})
+ }
+ }
+
+ case 1:
+ m.SetRGBA(p.X+x*xsign, p.Y+y*ysign, color.RGBA{128, 0, 0, 255})
+ }
+ }
+ if dx > dy {
+ for x < dx || y < dy {
+ pt()
+ x++
+ if float64(x)*float64(dy)/float64(dx)-float64(y) > 0.5 {
+ y++
+ }
+ }
+ } else {
+ for x < dx || y < dy {
+ pt()
+ y++
+ if float64(y)*float64(dx)/float64(dy)-float64(x) > 0.5 {
+ x++
+ }
+ }
+ }
+ pt()
+}
+
+func pngEncode(c image.Image) []byte {
+ var b bytes.Buffer
+ png.Encode(&b, c)
+ return b.Bytes()
+}
+
+// Frame handles a request for a single QR frame.
+func Frame(w http.ResponseWriter, req *http.Request) {
+ arg := func(s string) int { x, _ := strconv.Atoi(req.FormValue(s)); return x }
+ v := arg("v")
+ scale := arg("scale")
+ if scale == 0 {
+ scale = 8
+ }
+
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(pngEncode(makeFrame(req, req.FormValue("font"), arg("pt"), v, arg("l"), scale, arg("dots"))))
+}
+
+// Frames handles a request for multiple QR frames.
+func Frames(w http.ResponseWriter, req *http.Request) {
+ vs := strings.Split(req.FormValue("v"), ",")
+
+ arg := func(s string) int { x, _ := strconv.Atoi(req.FormValue(s)); return x }
+ scale := arg("scale")
+ if scale == 0 {
+ scale = 8
+ }
+ font := req.FormValue("font")
+ pt := arg("pt")
+ dots := arg("dots")
+
+ var images []image.Image
+ l := arg("l")
+ for _, v := range vs {
+ l := l
+ if i := strings.Index(v, "."); i >= 0 {
+ l, _ = strconv.Atoi(v[i+1:])
+ v = v[:i]
+ }
+ vv, _ := strconv.Atoi(v)
+ images = append(images, makeFrame(req, font, pt, vv, l, scale, dots))
+ }
+
+ b := images[len(images)-1].Bounds()
+
+ dx := arg("dx")
+ if dx == 0 {
+ dx = b.Dx()
+ }
+ x, y := 0, 0
+ xmax := 0
+ sep := arg("sep")
+ if sep == 0 {
+ sep = 10
+ }
+ var points []image.Point
+ for i, m := range images {
+ if x > 0 {
+ x += sep
+ }
+ if x > 0 && x+m.Bounds().Dx() > dx {
+ y += sep + images[i-1].Bounds().Dy()
+ x = 0
+ }
+ points = append(points, image.Point{x, y})
+ x += m.Bounds().Dx()
+ if x > xmax {
+ xmax = x
+ }
+
+ }
+
+ c := image.NewRGBA(image.Rect(0, 0, xmax, y+b.Dy()))
+ for i, m := range images {
+ draw.Draw(c, c.Bounds().Add(points[i]), m, image.ZP, draw.Src)
+ }
+
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(pngEncode(c))
+}
+
+// Mask handles a request for a single QR mask.
+func Mask(w http.ResponseWriter, req *http.Request) {
+ arg := func(s string) int { x, _ := strconv.Atoi(req.FormValue(s)); return x }
+ v := arg("v")
+ m := arg("m")
+ scale := arg("scale")
+ if scale == 0 {
+ scale = 8
+ }
+
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(pngEncode(makeMask(req, req.FormValue("font"), arg("pt"), v, m, scale)))
+}
+
+// Masks handles a request for multiple QR masks.
+func Masks(w http.ResponseWriter, req *http.Request) {
+ arg := func(s string) int { x, _ := strconv.Atoi(req.FormValue(s)); return x }
+ v := arg("v")
+ scale := arg("scale")
+ if scale == 0 {
+ scale = 8
+ }
+ font := req.FormValue("font")
+ pt := arg("pt")
+ var mm []image.Image
+ for m := 0; m < 8; m++ {
+ mm = append(mm, makeMask(req, font, pt, v, m, scale))
+ }
+ dx := mm[0].Bounds().Dx()
+ dy := mm[0].Bounds().Dy()
+
+ sep := arg("sep")
+ if sep == 0 {
+ sep = 10
+ }
+ c := image.NewRGBA(image.Rect(0, 0, (dx+sep)*4-sep, (dy+sep)*2-sep))
+ for m := 0; m < 8; m++ {
+ x := (m % 4) * (dx + sep)
+ y := (m / 4) * (dy + sep)
+ draw.Draw(c, c.Bounds().Add(image.Pt(x, y)), mm[m], image.ZP, draw.Src)
+ }
+
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(pngEncode(c))
+}
+
+var maskName = []string{
+ "(x+y) % 2",
+ "y % 2",
+ "x % 3",
+ "(x+y) % 3",
+ "(y/2 + x/3) % 2",
+ "xy%2 + xy%3",
+ "(xy%2 + xy%3) % 2",
+ "(xy%3 + (x+y)%2) % 2",
+}
+
+func makeMask(req *http.Request, font string, pt int, vers, mask, scale int) image.Image {
+ p, err := coding.NewPlan(coding.Version(vers), coding.L, coding.Mask(mask))
+ if err != nil {
+ panic(err)
+ }
+ m := makeImage(req, maskName[mask], font, pt, len(p.Pixel), 0, scale, func(x, y int) uint32 {
+ pix := p.Pixel[y][x]
+ switch pix.Role() {
+ case coding.Data, coding.Check:
+ if pix&coding.Invert != 0 {
+ return 0x000000ff
+ }
+ }
+ return 0xffffffff
+ })
+ return m
+}
+
+var blockColors = []uint32{
+ 0x7777ffff,
+ 0xffff77ff,
+ 0xff7777ff,
+ 0x77ffffff,
+ 0x1e90ffff,
+ 0xffffe0ff,
+ 0x8b6969ff,
+ 0x77ff77ff,
+ 0x9b30ffff,
+ 0x00bfffff,
+ 0x90e890ff,
+ 0xfff68fff,
+ 0xffec8bff,
+ 0xffa07aff,
+ 0xffa54fff,
+ 0xeee8aaff,
+ 0x98fb98ff,
+ 0xbfbfbfff,
+ 0x54ff9fff,
+ 0xffaeb9ff,
+ 0xb23aeeff,
+ 0xbbffffff,
+ 0x7fffd4ff,
+ 0xff7a7aff,
+ 0x00007fff,
+}
+
+func dark(x uint32) uint32 {
+ r, g, b, a := byte(x>>24), byte(x>>16), byte(x>>8), byte(x)
+ r = r/2 + r/4
+ g = g/2 + g/4
+ b = b/2 + b/4
+ return uint32(r)<<24 | uint32(g)<<16 | uint32(b)<<8 | uint32(a)
+}
+
+func clamp(x int) byte {
+ if x < 0 {
+ return 0
+ }
+ if x > 255 {
+ return 255
+ }
+ return byte(x)
+}
+
+func max(x, y int) int {
+ if x > y {
+ return x
+ }
+ return y
+}
+
+// Arrow handles a request for an arrow pointing in a given direction.
+func Arrow(w http.ResponseWriter, req *http.Request) {
+ arg := func(s string) int { x, _ := strconv.Atoi(req.FormValue(s)); return x }
+ dir := arg("dir")
+ size := arg("size")
+ if size == 0 {
+ size = 50
+ }
+ del := size / 10
+
+ m := image.NewRGBA(image.Rect(0, 0, size, size))
+
+ if dir == 4 {
+ draw.Draw(m, m.Bounds(), image.Black, image.ZP, draw.Src)
+ draw.Draw(m, image.Rect(5, 5, size-5, size-5), image.White, image.ZP, draw.Src)
+ }
+
+ pt := func(x, y int, c color.RGBA) {
+ switch dir {
+ case 0:
+ m.SetRGBA(x, y, c)
+ case 1:
+ m.SetRGBA(y, size-1-x, c)
+ case 2:
+ m.SetRGBA(size-1-x, size-1-y, c)
+ case 3:
+ m.SetRGBA(size-1-y, x, c)
+ }
+ }
+
+ for y := 0; y < size/2; y++ {
+ for x := 0; x < del && x < y; x++ {
+ pt(x, y, color.RGBA{0, 0, 0, 255})
+ }
+ for x := del; x < y-del; x++ {
+ pt(x, y, color.RGBA{128, 128, 255, 255})
+ }
+ for x := max(y-del, 0); x <= y; x++ {
+ pt(x, y, color.RGBA{0, 0, 0, 255})
+ }
+ }
+ for y := size / 2; y < size; y++ {
+ for x := 0; x < del && x < size-1-y; x++ {
+ pt(x, y, color.RGBA{0, 0, 0, 255})
+ }
+ for x := del; x < size-1-y-del; x++ {
+ pt(x, y, color.RGBA{128, 128, 192, 255})
+ }
+ for x := max(size-1-y-del, 0); x <= size-1-y; x++ {
+ pt(x, y, color.RGBA{0, 0, 0, 255})
+ }
+ }
+
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(pngEncode(m))
+}
+
+// Encode encodes a string using the given version, level, and mask.
+func Encode(w http.ResponseWriter, req *http.Request) {
+ val := func(s string) int {
+ v, _ := strconv.Atoi(req.FormValue(s))
+ return v
+ }
+
+ l := coding.Level(val("l"))
+ v := coding.Version(val("v"))
+ enc := coding.String(req.FormValue("t"))
+ m := coding.Mask(val("m"))
+
+ p, err := coding.NewPlan(v, l, m)
+ if err != nil {
+ panic(err)
+ }
+ cc, err := p.Encode(enc)
+ if err != nil {
+ panic(err)
+ }
+
+ c := &qr.Code{Bitmap: cc.Bitmap, Size: cc.Size, Stride: cc.Stride, Scale: 8}
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(c.PNG())
+}
+
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/play.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/play.go
new file mode 100644
index 000000000..120f50b81
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/play.go
@@ -0,0 +1,1118 @@
+// Copyright 2012 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+QR data layout
+
+qr/
+ upload/
+ id.png
+ id.fix
+ flag/
+ id
+
+*/
+// TODO: Random seed taken from GET for caching, repeatability.
+// TODO: Flag for abuse button + some kind of dashboard.
+// TODO: +1 button on web page? permalink?
+// TODO: Flag for abuse button on permalinks too?
+// TODO: Make the page prettier.
+// TODO: Cache headers.
+
+package web
+
+import (
+ "bytes"
+ "crypto/md5"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "image"
+ "image/color"
+ _ "image/gif"
+ _ "image/jpeg"
+ "image/png"
+ "io"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mattermost/rsc/appfs/fs"
+ "github.com/mattermost/rsc/gf256"
+ "github.com/mattermost/rsc/qr"
+ "github.com/mattermost/rsc/qr/coding"
+ "github.com/mattermost/rsc/qr/web/resize"
+)
+
+func runTemplate(c *fs.Context, w http.ResponseWriter, name string, data interface{}) {
+ t := template.New("main")
+
+ main, _, err := c.Read(name)
+ if err != nil {
+ panic(err)
+ }
+ style, _, _ := c.Read("style.html")
+ main = append(main, style...)
+ _, err = t.Parse(string(main))
+ if err != nil {
+ panic(err)
+ }
+
+ var buf bytes.Buffer
+ if err := t.Execute(&buf, &data); err != nil {
+ panic(err)
+ }
+ w.Write(buf.Bytes())
+}
+
+func isImgName(s string) bool {
+ if len(s) != 32 {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ if '0' <= s[i] && s[i] <= '9' || 'a' <= s[i] && s[i] <= 'f' {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func isTagName(s string) bool {
+ if len(s) != 16 {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ if '0' <= s[i] && s[i] <= '9' || 'a' <= s[i] && s[i] <= 'f' {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+// Draw is the handler for drawing a QR code.
+func Draw(w http.ResponseWriter, req *http.Request) {
+ ctxt := fs.NewContext(req)
+
+ url := req.FormValue("url")
+ if url == "" {
+ url = "http://swtch.com/qr"
+ }
+ if req.FormValue("upload") == "1" {
+ upload(w, req, url)
+ return
+ }
+
+ t0 := time.Now()
+ img := req.FormValue("i")
+ if !isImgName(img) {
+ img = "pjw"
+ }
+ if req.FormValue("show") == "png" {
+ i := loadSize(ctxt, img, 48)
+ var buf bytes.Buffer
+ png.Encode(&buf, i)
+ w.Write(buf.Bytes())
+ return
+ }
+ if req.FormValue("flag") == "1" {
+ flag(w, req, img, ctxt)
+ return
+ }
+ if req.FormValue("x") == "" {
+ var data = struct {
+ Name string
+ URL string
+ }{
+ Name: img,
+ URL: url,
+ }
+ runTemplate(ctxt, w, "qr/main.html", &data)
+ return
+ }
+
+ arg := func(s string) int { x, _ := strconv.Atoi(req.FormValue(s)); return x }
+ targ := makeTarg(ctxt, img, 17+4*arg("v")+arg("z"))
+
+ m := &Image{
+ Name: img,
+ Dx: arg("x"),
+ Dy: arg("y"),
+ URL: req.FormValue("u"),
+ Version: arg("v"),
+ Mask: arg("m"),
+ RandControl: arg("r") > 0,
+ Dither: arg("i") > 0,
+ OnlyDataBits: arg("d") > 0,
+ SaveControl: arg("c") > 0,
+ Scale: arg("scale"),
+ Target: targ,
+ Seed: int64(arg("s")),
+ Rotation: arg("o"),
+ Size: arg("z"),
+ }
+ if m.Version > 8 {
+ m.Version = 8
+ }
+
+ if m.Scale == 0 {
+ if arg("l") > 1 {
+ m.Scale = 8
+ } else {
+ m.Scale = 4
+ }
+ }
+ if m.Version >= 12 && m.Scale >= 4 {
+ m.Scale /= 2
+ }
+
+ if arg("l") == 1 {
+ data, err := json.Marshal(m)
+ if err != nil {
+ panic(err)
+ }
+ h := md5.New()
+ h.Write(data)
+ tag := fmt.Sprintf("%x", h.Sum(nil))[:16]
+ if err := ctxt.Write("qrsave/"+tag, data); err != nil {
+ panic(err)
+ }
+ http.Redirect(w, req, "/qr/show/" + tag, http.StatusTemporaryRedirect)
+ return
+ }
+
+ if err := m.Encode(req); err != nil {
+ fmt.Fprintf(w, "%s\n", err)
+ return
+ }
+
+ var dat []byte
+ switch {
+ case m.SaveControl:
+ dat = m.Control
+ default:
+ dat = m.Code.PNG()
+ }
+
+ if arg("l") > 0 {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(dat)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, "<center><img src=\"data:image/png;base64,")
+ io.WriteString(w, base64.StdEncoding.EncodeToString(dat))
+ fmt.Fprint(w, "\" /><br>")
+ fmt.Fprintf(w, "<form method=\"POST\" action=\"%s&l=1\"><input type=\"submit\" value=\"Save this QR code\"></form>\n", m.Link())
+ fmt.Fprintf(w, "</center>\n")
+ fmt.Fprintf(w, "<br><center><font size=-1>%v</font></center>\n", time.Now().Sub(t0))
+}
+
+func (m *Image) Small() bool {
+ return 8*(17+4*int(m.Version)) < 512
+}
+
+func (m *Image) Link() string {
+ s := fmt.Sprint
+ b := func(v bool) string {
+ if v {
+ return "1"
+ }
+ return "0"
+ }
+ val := url.Values{
+ "i": {m.Name},
+ "x": {s(m.Dx)},
+ "y": {s(m.Dy)},
+ "z": {s(m.Size)},
+ "u": {m.URL},
+ "v": {s(m.Version)},
+ "m": {s(m.Mask)},
+ "r": {b(m.RandControl)},
+ "t": {b(m.Dither)},
+ "d": {b(m.OnlyDataBits)},
+ "c": {b(m.SaveControl)},
+ "s": {s(m.Seed)},
+ }
+ return "/qr/draw?" + val.Encode()
+}
+
+// Show is the handler for showing a stored QR code.
+func Show(w http.ResponseWriter, req *http.Request) {
+ ctxt := fs.NewContext(req)
+ tag := req.URL.Path[len("/qr/show/"):]
+ png := strings.HasSuffix(tag, ".png")
+ if png {
+ tag = tag[:len(tag)-len(".png")]
+ }
+ if !isTagName(tag) {
+ fmt.Fprintf(w, "Sorry, QR code not found\n")
+ return
+ }
+ if req.FormValue("flag") == "1" {
+ flag(w, req, tag, ctxt)
+ return
+ }
+ data, _, err := ctxt.Read("qrsave/" + tag)
+ if err != nil {
+ fmt.Fprintf(w, "Sorry, QR code not found.\n")
+ return
+ }
+
+ var m Image
+ if err := json.Unmarshal(data, &m); err != nil {
+ panic(err)
+ }
+ m.Tag = tag
+
+ switch req.FormValue("size") {
+ case "big":
+ m.Scale *= 2
+ case "small":
+ m.Scale /= 2
+ }
+
+ if png {
+ if err := m.Encode(req); err != nil {
+ panic(err)
+ return
+ }
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(m.Code.PNG())
+ return
+ }
+
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ runTemplate(ctxt, w, "qr/permalink.html", &m)
+}
+
+func upload(w http.ResponseWriter, req *http.Request, link string) {
+ // Upload of a new image.
+ // Copied from Moustachio demo.
+ f, _, err := req.FormFile("image")
+ if err != nil {
+ fmt.Fprintf(w, "You need to select an image to upload.\n")
+ return
+ }
+ defer f.Close()
+
+ i, _, err := image.Decode(f)
+ if err != nil {
+ panic(err)
+ }
+
+ // Convert image to 128x128 gray+alpha.
+ b := i.Bounds()
+ const max = 128
+ // If it's gigantic, it's more efficient to downsample first
+ // and then resize; resizing will smooth out the roughness.
+ var i1 *image.RGBA
+ if b.Dx() > 4*max || b.Dy() > 4*max {
+ w, h := 2*max, 2*max
+ if b.Dx() > b.Dy() {
+ h = b.Dy() * h / b.Dx()
+ } else {
+ w = b.Dx() * w / b.Dy()
+ }
+ i1 = resize.Resample(i, b, w, h)
+ } else {
+ // "Resample" to same size, just to convert to RGBA.
+ i1 = resize.Resample(i, b, b.Dx(), b.Dy())
+ }
+ b = i1.Bounds()
+
+ // Encode to PNG.
+ dx, dy := 128, 128
+ if b.Dx() > b.Dy() {
+ dy = b.Dy() * dx / b.Dx()
+ } else {
+ dx = b.Dx() * dy / b.Dy()
+ }
+ i128 := resize.ResizeRGBA(i1, i1.Bounds(), dx, dy)
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, i128); err != nil {
+ panic(err)
+ }
+
+ h := md5.New()
+ h.Write(buf.Bytes())
+ tag := fmt.Sprintf("%x", h.Sum(nil))[:32]
+
+ ctxt := fs.NewContext(req)
+ if err := ctxt.Write("qr/upload/"+tag+".png", buf.Bytes()); err != nil {
+ panic(err)
+ }
+
+ // Redirect with new image tag.
+ // Redirect to draw with new image tag.
+ http.Redirect(w, req, req.URL.Path+"?"+url.Values{"i": {tag}, "url": {link}}.Encode(), 302)
+}
+
+func flag(w http.ResponseWriter, req *http.Request, img string, ctxt *fs.Context) {
+ if !isImgName(img) && !isTagName(img) {
+ fmt.Fprintf(w, "Invalid image.\n")
+ return
+ }
+ data, _, _ := ctxt.Read("qr/flag/" + img)
+ data = append(data, '!')
+ ctxt.Write("qr/flag/" + img, data)
+
+ fmt.Fprintf(w, "Thank you. The image has been reported.\n")
+}
+
+func loadSize(ctxt *fs.Context, name string, max int) *image.RGBA {
+ data, _, err := ctxt.Read("qr/upload/" + name + ".png")
+ if err != nil {
+ panic(err)
+ }
+ i, _, err := image.Decode(bytes.NewBuffer(data))
+ if err != nil {
+ panic(err)
+ }
+ b := i.Bounds()
+ dx, dy := max, max
+ if b.Dx() > b.Dy() {
+ dy = b.Dy() * dx / b.Dx()
+ } else {
+ dx = b.Dx() * dy / b.Dy()
+ }
+ var irgba *image.RGBA
+ switch i := i.(type) {
+ case *image.RGBA:
+ irgba = resize.ResizeRGBA(i, i.Bounds(), dx, dy)
+ case *image.NRGBA:
+ irgba = resize.ResizeNRGBA(i, i.Bounds(), dx, dy)
+ }
+ return irgba
+}
+
+func makeTarg(ctxt *fs.Context, name string, max int) [][]int {
+ i := loadSize(ctxt, name, max)
+ b := i.Bounds()
+ dx, dy := b.Dx(), b.Dy()
+ targ := make([][]int, dy)
+ arr := make([]int, dx*dy)
+ for y := 0; y < dy; y++ {
+ targ[y], arr = arr[:dx], arr[dx:]
+ row := targ[y]
+ for x := 0; x < dx; x++ {
+ p := i.Pix[y*i.Stride+4*x:]
+ r, g, b, a := p[0], p[1], p[2], p[3]
+ if a == 0 {
+ row[x] = -1
+ } else {
+ row[x] = int((299*uint32(r) + 587*uint32(g) + 114*uint32(b) + 500) / 1000)
+ }
+ }
+ }
+ return targ
+}
+
+type Image struct {
+ Name string
+ Target [][]int
+ Dx int
+ Dy int
+ URL string
+ Tag string
+ Version int
+ Mask int
+ Scale int
+ Rotation int
+ Size int
+
+ // RandControl says to pick the pixels randomly.
+ RandControl bool
+ Seed int64
+
+ // Dither says to dither instead of using threshold pixel layout.
+ Dither bool
+
+ // OnlyDataBits says to use only data bits, not check bits.
+ OnlyDataBits bool
+
+ // Code is the final QR code.
+ Code *qr.Code
+
+ // Control is a PNG showing the pixels that we controlled.
+ // Pixels we don't control are grayed out.
+ SaveControl bool
+ Control []byte
+}
+
+type Pixinfo struct {
+ X int
+ Y int
+ Pix coding.Pixel
+ Targ byte
+ DTarg int
+ Contrast int
+ HardZero bool
+ Block *BitBlock
+ Bit uint
+}
+
+type Pixorder struct {
+ Off int
+ Priority int
+}
+
+type byPriority []Pixorder
+
+func (x byPriority) Len() int { return len(x) }
+func (x byPriority) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
+func (x byPriority) Less(i, j int) bool { return x[i].Priority > x[j].Priority }
+
+func (m *Image) target(x, y int) (targ byte, contrast int) {
+ tx := x + m.Dx
+ ty := y + m.Dy
+ if ty < 0 || ty >= len(m.Target) || tx < 0 || tx >= len(m.Target[ty]) {
+ return 255, -1
+ }
+
+ v0 := m.Target[ty][tx]
+ if v0 < 0 {
+ return 255, -1
+ }
+ targ = byte(v0)
+
+ n := 0
+ sum := 0
+ sumsq := 0
+ const del = 5
+ for dy := -del; dy <= del; dy++ {
+ for dx := -del; dx <= del; dx++ {
+ if 0 <= ty+dy && ty+dy < len(m.Target) && 0 <= tx+dx && tx+dx < len(m.Target[ty+dy]) {
+ v := m.Target[ty+dy][tx+dx]
+ sum += v
+ sumsq += v * v
+ n++
+ }
+ }
+ }
+
+ avg := sum / n
+ contrast = sumsq/n - avg*avg
+ return
+}
+
+func (m *Image) rotate(p *coding.Plan, rot int) {
+ if rot == 0 {
+ return
+ }
+
+ N := len(p.Pixel)
+ pix := make([][]coding.Pixel, N)
+ apix := make([]coding.Pixel, N*N)
+ for i := range pix {
+ pix[i], apix = apix[:N], apix[N:]
+ }
+
+ switch rot {
+ case 0:
+ // ok
+ case 1:
+ for y := 0; y < N; y++ {
+ for x := 0; x < N; x++ {
+ pix[y][x] = p.Pixel[x][N-1-y]
+ }
+ }
+ case 2:
+ for y := 0; y < N; y++ {
+ for x := 0; x < N; x++ {
+ pix[y][x] = p.Pixel[N-1-y][N-1-x]
+ }
+ }
+ case 3:
+ for y := 0; y < N; y++ {
+ for x := 0; x < N; x++ {
+ pix[y][x] = p.Pixel[N-1-x][y]
+ }
+ }
+ }
+
+ p.Pixel = pix
+}
+
+func (m *Image) Encode(req *http.Request) error {
+ p, err := coding.NewPlan(coding.Version(m.Version), coding.L, coding.Mask(m.Mask))
+ if err != nil {
+ return err
+ }
+
+ m.rotate(p, m.Rotation)
+
+ rand := rand.New(rand.NewSource(m.Seed))
+
+ // QR parameters.
+ nd := p.DataBytes / p.Blocks
+ nc := p.CheckBytes / p.Blocks
+ extra := p.DataBytes - nd*p.Blocks
+ rs := gf256.NewRSEncoder(coding.Field, nc)
+
+ // Build information about pixels, indexed by data/check bit number.
+ pixByOff := make([]Pixinfo, (p.DataBytes+p.CheckBytes)*8)
+ expect := make([][]bool, len(p.Pixel))
+ for y, row := range p.Pixel {
+ expect[y] = make([]bool, len(row))
+ for x, pix := range row {
+ targ, contrast := m.target(x, y)
+ if m.RandControl && contrast >= 0 {
+ contrast = rand.Intn(128) + 64*((x+y)%2) + 64*((x+y)%3%2)
+ }
+ expect[y][x] = pix&coding.Black != 0
+ if r := pix.Role(); r == coding.Data || r == coding.Check {
+ pixByOff[pix.Offset()] = Pixinfo{X: x, Y: y, Pix: pix, Targ: targ, Contrast: contrast}
+ }
+ }
+ }
+
+Again:
+ // Count fixed initial data bits, prepare template URL.
+ url := m.URL + "#"
+ var b coding.Bits
+ coding.String(url).Encode(&b, p.Version)
+ coding.Num("").Encode(&b, p.Version)
+ bbit := b.Bits()
+ dbit := p.DataBytes*8 - bbit
+ if dbit < 0 {
+ return fmt.Errorf("cannot encode URL into available bits")
+ }
+ num := make([]byte, dbit/10*3)
+ for i := range num {
+ num[i] = '0'
+ }
+ b.Pad(dbit)
+ b.Reset()
+ coding.String(url).Encode(&b, p.Version)
+ coding.Num(num).Encode(&b, p.Version)
+ b.AddCheckBytes(p.Version, p.Level)
+ data := b.Bytes()
+
+ doff := 0 // data offset
+ coff := 0 // checksum offset
+ mbit := bbit + dbit/10*10
+
+ // Choose pixels.
+ bitblocks := make([]*BitBlock, p.Blocks)
+ for blocknum := 0; blocknum < p.Blocks; blocknum++ {
+ if blocknum == p.Blocks-extra {
+ nd++
+ }
+
+ bdata := data[doff/8 : doff/8+nd]
+ cdata := data[p.DataBytes+coff/8 : p.DataBytes+coff/8+nc]
+ bb := newBlock(nd, nc, rs, bdata, cdata)
+ bitblocks[blocknum] = bb
+
+ // Determine which bits in this block we can try to edit.
+ lo, hi := 0, nd*8
+ if lo < bbit-doff {
+ lo = bbit - doff
+ if lo > hi {
+ lo = hi
+ }
+ }
+ if hi > mbit-doff {
+ hi = mbit - doff
+ if hi < lo {
+ hi = lo
+ }
+ }
+
+ // Preserve [0, lo) and [hi, nd*8).
+ for i := 0; i < lo; i++ {
+ if !bb.canSet(uint(i), (bdata[i/8]>>uint(7-i&7))&1) {
+ return fmt.Errorf("cannot preserve required bits")
+ }
+ }
+ for i := hi; i < nd*8; i++ {
+ if !bb.canSet(uint(i), (bdata[i/8]>>uint(7-i&7))&1) {
+ return fmt.Errorf("cannot preserve required bits")
+ }
+ }
+
+ // Can edit [lo, hi) and checksum bits to hit target.
+ // Determine which ones to try first.
+ order := make([]Pixorder, (hi-lo)+nc*8)
+ for i := lo; i < hi; i++ {
+ order[i-lo].Off = doff + i
+ }
+ for i := 0; i < nc*8; i++ {
+ order[hi-lo+i].Off = p.DataBytes*8 + coff + i
+ }
+ if m.OnlyDataBits {
+ order = order[:hi-lo]
+ }
+ for i := range order {
+ po := &order[i]
+ po.Priority = pixByOff[po.Off].Contrast<<8 | rand.Intn(256)
+ }
+ sort.Sort(byPriority(order))
+
+ const mark = false
+ for i := range order {
+ po := &order[i]
+ pinfo := &pixByOff[po.Off]
+ bval := pinfo.Targ
+ if bval < 128 {
+ bval = 1
+ } else {
+ bval = 0
+ }
+ pix := pinfo.Pix
+ if pix&coding.Invert != 0 {
+ bval ^= 1
+ }
+ if pinfo.HardZero {
+ bval = 0
+ }
+
+ var bi int
+ if pix.Role() == coding.Data {
+ bi = po.Off - doff
+ } else {
+ bi = po.Off - p.DataBytes*8 - coff + nd*8
+ }
+ if bb.canSet(uint(bi), bval) {
+ pinfo.Block = bb
+ pinfo.Bit = uint(bi)
+ if mark {
+ p.Pixel[pinfo.Y][pinfo.X] = coding.Black
+ }
+ } else {
+ if pinfo.HardZero {
+ panic("hard zero")
+ }
+ if mark {
+ p.Pixel[pinfo.Y][pinfo.X] = 0
+ }
+ }
+ }
+ bb.copyOut()
+
+ const cheat = false
+ for i := 0; i < nd*8; i++ {
+ pinfo := &pixByOff[doff+i]
+ pix := p.Pixel[pinfo.Y][pinfo.X]
+ if bb.B[i/8]&(1<<uint(7-i&7)) != 0 {
+ pix ^= coding.Black
+ }
+ expect[pinfo.Y][pinfo.X] = pix&coding.Black != 0
+ if cheat {
+ p.Pixel[pinfo.Y][pinfo.X] = pix & coding.Black
+ }
+ }
+ for i := 0; i < nc*8; i++ {
+ pinfo := &pixByOff[p.DataBytes*8+coff+i]
+ pix := p.Pixel[pinfo.Y][pinfo.X]
+ if bb.B[nd+i/8]&(1<<uint(7-i&7)) != 0 {
+ pix ^= coding.Black
+ }
+ expect[pinfo.Y][pinfo.X] = pix&coding.Black != 0
+ if cheat {
+ p.Pixel[pinfo.Y][pinfo.X] = pix & coding.Black
+ }
+ }
+ doff += nd * 8
+ coff += nc * 8
+ }
+
+ // Pass over all pixels again, dithering.
+ if m.Dither {
+ for i := range pixByOff {
+ pinfo := &pixByOff[i]
+ pinfo.DTarg = int(pinfo.Targ)
+ }
+ for y, row := range p.Pixel {
+ for x, pix := range row {
+ if pix.Role() != coding.Data && pix.Role() != coding.Check {
+ continue
+ }
+ pinfo := &pixByOff[pix.Offset()]
+ if pinfo.Block == nil {
+ // did not choose this pixel
+ continue
+ }
+
+ pix := pinfo.Pix
+
+ pval := byte(1) // pixel value (black)
+ v := 0 // gray value (black)
+ targ := pinfo.DTarg
+ if targ >= 128 {
+ // want white
+ pval = 0
+ v = 255
+ }
+
+ bval := pval // bit value
+ if pix&coding.Invert != 0 {
+ bval ^= 1
+ }
+ if pinfo.HardZero && bval != 0 {
+ bval ^= 1
+ pval ^= 1
+ v ^= 255
+ }
+
+ // Set pixel value as we want it.
+ pinfo.Block.reset(pinfo.Bit, bval)
+
+ _, _ = x, y
+
+ err := targ - v
+ if x+1 < len(row) {
+ addDither(pixByOff, row[x+1], err*7/16)
+ }
+ if false && y+1 < len(p.Pixel) {
+ if x > 0 {
+ addDither(pixByOff, p.Pixel[y+1][x-1], err*3/16)
+ }
+ addDither(pixByOff, p.Pixel[y+1][x], err*5/16)
+ if x+1 < len(row) {
+ addDither(pixByOff, p.Pixel[y+1][x+1], err*1/16)
+ }
+ }
+ }
+ }
+
+ for _, bb := range bitblocks {
+ bb.copyOut()
+ }
+ }
+
+ noops := 0
+ // Copy numbers back out.
+ for i := 0; i < dbit/10; i++ {
+ // Pull out 10 bits.
+ v := 0
+ for j := 0; j < 10; j++ {
+ bi := uint(bbit + 10*i + j)
+ v <<= 1
+ v |= int((data[bi/8] >> (7 - bi&7)) & 1)
+ }
+ // Turn into 3 digits.
+ if v >= 1000 {
+ // Oops - too many 1 bits.
+ // We know the 512, 256, 128, 64, 32 bits are all set.
+ // Pick one at random to clear. This will break some
+ // checksum bits, but so be it.
+ println("oops", i, v)
+ pinfo := &pixByOff[bbit+10*i+3] // TODO random
+ pinfo.Contrast = 1e9 >> 8
+ pinfo.HardZero = true
+ noops++
+ }
+ num[i*3+0] = byte(v/100 + '0')
+ num[i*3+1] = byte(v/10%10 + '0')
+ num[i*3+2] = byte(v%10 + '0')
+ }
+ if noops > 0 {
+ goto Again
+ }
+
+ var b1 coding.Bits
+ coding.String(url).Encode(&b1, p.Version)
+ coding.Num(num).Encode(&b1, p.Version)
+ b1.AddCheckBytes(p.Version, p.Level)
+ if !bytes.Equal(b.Bytes(), b1.Bytes()) {
+ fmt.Printf("mismatch\n%d %x\n%d %x\n", len(b.Bytes()), b.Bytes(), len(b1.Bytes()), b1.Bytes())
+ panic("byte mismatch")
+ }
+
+ cc, err := p.Encode(coding.String(url), coding.Num(num))
+ if err != nil {
+ return err
+ }
+
+ if !m.Dither {
+ for y, row := range expect {
+ for x, pix := range row {
+ if cc.Black(x, y) != pix {
+ println("mismatch", x, y, p.Pixel[y][x].String())
+ }
+ }
+ }
+ }
+
+ m.Code = &qr.Code{Bitmap: cc.Bitmap, Size: cc.Size, Stride: cc.Stride, Scale: m.Scale}
+
+ if m.SaveControl {
+ m.Control = pngEncode(makeImage(req, "", "", 0, cc.Size, 4, m.Scale, func(x, y int) (rgba uint32) {
+ pix := p.Pixel[y][x]
+ if pix.Role() == coding.Data || pix.Role() == coding.Check {
+ pinfo := &pixByOff[pix.Offset()]
+ if pinfo.Block != nil {
+ if cc.Black(x, y) {
+ return 0x000000ff
+ }
+ return 0xffffffff
+ }
+ }
+ if cc.Black(x, y) {
+ return 0x3f3f3fff
+ }
+ return 0xbfbfbfff
+ }))
+ }
+
+ return nil
+}
+
+func addDither(pixByOff []Pixinfo, pix coding.Pixel, err int) {
+ if pix.Role() != coding.Data && pix.Role() != coding.Check {
+ return
+ }
+ pinfo := &pixByOff[pix.Offset()]
+ println("add", pinfo.X, pinfo.Y, pinfo.DTarg, err)
+ pinfo.DTarg += err
+}
+
+func readTarget(name string) ([][]int, error) {
+ f, err := os.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ m, err := png.Decode(f)
+ if err != nil {
+ return nil, fmt.Errorf("decode %s: %v", name, err)
+ }
+ rect := m.Bounds()
+ target := make([][]int, rect.Dy())
+ for i := range target {
+ target[i] = make([]int, rect.Dx())
+ }
+ for y, row := range target {
+ for x := range row {
+ a := int(color.RGBAModel.Convert(m.At(x, y)).(color.RGBA).A)
+ t := int(color.GrayModel.Convert(m.At(x, y)).(color.Gray).Y)
+ if a == 0 {
+ t = -1
+ }
+ row[x] = t
+ }
+ }
+ return target, nil
+}
+
+type BitBlock struct {
+ DataBytes int
+ CheckBytes int
+ B []byte
+ M [][]byte
+ Tmp []byte
+ RS *gf256.RSEncoder
+ bdata []byte
+ cdata []byte
+}
+
+func newBlock(nd, nc int, rs *gf256.RSEncoder, dat, cdata []byte) *BitBlock {
+ b := &BitBlock{
+ DataBytes: nd,
+ CheckBytes: nc,
+ B: make([]byte, nd+nc),
+ Tmp: make([]byte, nc),
+ RS: rs,
+ bdata: dat,
+ cdata: cdata,
+ }
+ copy(b.B, dat)
+ rs.ECC(b.B[:nd], b.B[nd:])
+ b.check()
+ if !bytes.Equal(b.Tmp, cdata) {
+ panic("cdata")
+ }
+
+ b.M = make([][]byte, nd*8)
+ for i := range b.M {
+ row := make([]byte, nd+nc)
+ b.M[i] = row
+ for j := range row {
+ row[j] = 0
+ }
+ row[i/8] = 1 << (7 - uint(i%8))
+ rs.ECC(row[:nd], row[nd:])
+ }
+ return b
+}
+
+func (b *BitBlock) check() {
+ b.RS.ECC(b.B[:b.DataBytes], b.Tmp)
+ if !bytes.Equal(b.B[b.DataBytes:], b.Tmp) {
+ fmt.Printf("ecc mismatch\n%x\n%x\n", b.B[b.DataBytes:], b.Tmp)
+ panic("mismatch")
+ }
+}
+
+func (b *BitBlock) reset(bi uint, bval byte) {
+ if (b.B[bi/8]>>(7-bi&7))&1 == bval {
+ // already has desired bit
+ return
+ }
+ // rows that have already been set
+ m := b.M[len(b.M):cap(b.M)]
+ for _, row := range m {
+ if row[bi/8]&(1<<(7-bi&7)) != 0 {
+ // Found it.
+ for j, v := range row {
+ b.B[j] ^= v
+ }
+ return
+ }
+ }
+ panic("reset of unset bit")
+}
+
+func (b *BitBlock) canSet(bi uint, bval byte) bool {
+ found := false
+ m := b.M
+ for j, row := range m {
+ if row[bi/8]&(1<<(7-bi&7)) == 0 {
+ continue
+ }
+ if !found {
+ found = true
+ if j != 0 {
+ m[0], m[j] = m[j], m[0]
+ }
+ continue
+ }
+ for k := range row {
+ row[k] ^= m[0][k]
+ }
+ }
+ if !found {
+ return false
+ }
+
+ targ := m[0]
+
+ // Subtract from saved-away rows too.
+ for _, row := range m[len(m):cap(m)] {
+ if row[bi/8]&(1<<(7-bi&7)) == 0 {
+ continue
+ }
+ for k := range row {
+ row[k] ^= targ[k]
+ }
+ }
+
+ // Found a row with bit #bi == 1 and cut that bit from all the others.
+ // Apply to data and remove from m.
+ if (b.B[bi/8]>>(7-bi&7))&1 != bval {
+ for j, v := range targ {
+ b.B[j] ^= v
+ }
+ }
+ b.check()
+ n := len(m) - 1
+ m[0], m[n] = m[n], m[0]
+ b.M = m[:n]
+
+ for _, row := range b.M {
+ if row[bi/8]&(1<<(7-bi&7)) != 0 {
+ panic("did not reduce")
+ }
+ }
+
+ return true
+}
+
+func (b *BitBlock) copyOut() {
+ b.check()
+ copy(b.bdata, b.B[:b.DataBytes])
+ copy(b.cdata, b.B[b.DataBytes:])
+}
+
+func showtable(w http.ResponseWriter, b *BitBlock, gray func(int) bool) {
+ nd := b.DataBytes
+ nc := b.CheckBytes
+
+ fmt.Fprintf(w, "<table class='matrix' cellspacing=0 cellpadding=0 border=0>\n")
+ line := func() {
+ fmt.Fprintf(w, "<tr height=1 bgcolor='#bbbbbb'><td colspan=%d>\n", (nd+nc)*8)
+ }
+ line()
+ dorow := func(row []byte) {
+ fmt.Fprintf(w, "<tr>\n")
+ for i := 0; i < (nd+nc)*8; i++ {
+ fmt.Fprintf(w, "<td")
+ v := row[i/8] >> uint(7-i&7) & 1
+ if gray(i) {
+ fmt.Fprintf(w, " class='gray'")
+ }
+ fmt.Fprintf(w, ">")
+ if v == 1 {
+ fmt.Fprintf(w, "1")
+ }
+ }
+ line()
+ }
+
+ m := b.M[len(b.M):cap(b.M)]
+ for i := len(m) - 1; i >= 0; i-- {
+ dorow(m[i])
+ }
+ m = b.M
+ for _, row := range b.M {
+ dorow(row)
+ }
+
+ fmt.Fprintf(w, "</table>\n")
+}
+
+func BitsTable(w http.ResponseWriter, req *http.Request) {
+ nd := 2
+ nc := 2
+ fmt.Fprintf(w, `<html>
+ <style type='text/css'>
+ .matrix {
+ font-family: sans-serif;
+ font-size: 0.8em;
+ }
+ table.matrix {
+ padding-left: 1em;
+ padding-right: 1em;
+ padding-top: 1em;
+ padding-bottom: 1em;
+ }
+ .matrix td {
+ padding-left: 0.3em;
+ padding-right: 0.3em;
+ border-left: 2px solid white;
+ border-right: 2px solid white;
+ text-align: center;
+ color: #aaa;
+ }
+ .matrix td.gray {
+ color: black;
+ background-color: #ddd;
+ }
+ </style>
+ `)
+ rs := gf256.NewRSEncoder(coding.Field, nc)
+ dat := make([]byte, nd+nc)
+ b := newBlock(nd, nc, rs, dat[:nd], dat[nd:])
+ for i := 0; i < nd*8; i++ {
+ b.canSet(uint(i), 0)
+ }
+ showtable(w, b, func(i int) bool { return i < nd*8 })
+
+ b = newBlock(nd, nc, rs, dat[:nd], dat[nd:])
+ for j := 0; j < (nd+nc)*8; j += 2 {
+ b.canSet(uint(j), 0)
+ }
+ showtable(w, b, func(i int) bool { return i%2 == 0 })
+
+}
diff --git a/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/resize/resize.go b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/resize/resize.go
new file mode 100644
index 000000000..02c8b0040
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/mattermost/rsc/qr/web/resize/resize.go
@@ -0,0 +1,152 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package resize
+
+import (
+ "image"
+ "image/color"
+)
+
+// average convert the sums to averages and returns the result.
+func average(sum []uint64, w, h int, n uint64) *image.RGBA {
+ ret := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ index := 4 * (y*w + x)
+ pix := ret.Pix[y*ret.Stride+x*4:]
+ pix[0] = uint8(sum[index+0] / n)
+ pix[1] = uint8(sum[index+1] / n)
+ pix[2] = uint8(sum[index+2] / n)
+ pix[3] = uint8(sum[index+3] / n)
+ }
+ }
+ return ret
+}
+
+// ResizeRGBA returns a scaled copy of the RGBA image slice r of m.
+// The returned image has width w and height h.
+func ResizeRGBA(m *image.RGBA, r image.Rectangle, w, h int) *image.RGBA {
+ ww, hh := uint64(w), uint64(h)
+ dx, dy := uint64(r.Dx()), uint64(r.Dy())
+ // See comment in Resize.
+ n, sum := dx*dy, make([]uint64, 4*w*h)
+ for y := r.Min.Y; y < r.Max.Y; y++ {
+ pix := m.Pix[(y-r.Min.Y)*m.Stride:]
+ for x := r.Min.X; x < r.Max.X; x++ {
+ // Get the source pixel.
+ p := pix[(x-r.Min.X)*4:]
+ r64 := uint64(p[0])
+ g64 := uint64(p[1])
+ b64 := uint64(p[2])
+ a64 := uint64(p[3])
+ // Spread the source pixel over 1 or more destination rows.
+ py := uint64(y) * hh
+ for remy := hh; remy > 0; {
+ qy := dy - (py % dy)
+ if qy > remy {
+ qy = remy
+ }
+ // Spread the source pixel over 1 or more destination columns.
+ px := uint64(x) * ww
+ index := 4 * ((py/dy)*ww + (px / dx))
+ for remx := ww; remx > 0; {
+ qx := dx - (px % dx)
+ if qx > remx {
+ qx = remx
+ }
+ qxy := qx * qy
+ sum[index+0] += r64 * qxy
+ sum[index+1] += g64 * qxy
+ sum[index+2] += b64 * qxy
+ sum[index+3] += a64 * qxy
+ index += 4
+ px += qx
+ remx -= qx
+ }
+ py += qy
+ remy -= qy
+ }
+ }
+ }
+ return average(sum, w, h, n)
+}
+
+// ResizeNRGBA returns a scaled copy of the RGBA image slice r of m.
+// The returned image has width w and height h.
+func ResizeNRGBA(m *image.NRGBA, r image.Rectangle, w, h int) *image.RGBA {
+ ww, hh := uint64(w), uint64(h)
+ dx, dy := uint64(r.Dx()), uint64(r.Dy())
+ // See comment in Resize.
+ n, sum := dx*dy, make([]uint64, 4*w*h)
+ for y := r.Min.Y; y < r.Max.Y; y++ {
+ pix := m.Pix[(y-r.Min.Y)*m.Stride:]
+ for x := r.Min.X; x < r.Max.X; x++ {
+ // Get the source pixel.
+ p := pix[(x-r.Min.X)*4:]
+ r64 := uint64(p[0])
+ g64 := uint64(p[1])
+ b64 := uint64(p[2])
+ a64 := uint64(p[3])
+ r64 = (r64 * a64) / 255
+ g64 = (g64 * a64) / 255
+ b64 = (b64 * a64) / 255
+ // Spread the source pixel over 1 or more destination rows.
+ py := uint64(y) * hh
+ for remy := hh; remy > 0; {
+ qy := dy - (py % dy)
+ if qy > remy {
+ qy = remy
+ }
+ // Spread the source pixel over 1 or more destination columns.
+ px := uint64(x) * ww
+ index := 4 * ((py/dy)*ww + (px / dx))
+ for remx := ww; remx > 0; {
+ qx := dx - (px % dx)
+ if qx > remx {
+ qx = remx
+ }
+ qxy := qx * qy
+ sum[index+0] += r64 * qxy
+ sum[index+1] += g64 * qxy
+ sum[index+2] += b64 * qxy
+ sum[index+3] += a64 * qxy
+ index += 4
+ px += qx
+ remx -= qx
+ }
+ py += qy
+ remy -= qy
+ }
+ }
+ }
+ return average(sum, w, h, n)
+}
+
+// Resample returns a resampled copy of the image slice r of m.
+// The returned image has width w and height h.
+func Resample(m image.Image, r image.Rectangle, w, h int) *image.RGBA {
+ if w < 0 || h < 0 {
+ return nil
+ }
+ if w == 0 || h == 0 || r.Dx() <= 0 || r.Dy() <= 0 {
+ return image.NewRGBA(image.Rect(0, 0, w, h))
+ }
+ curw, curh := r.Dx(), r.Dy()
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ // Get a source pixel.
+ subx := x * curw / w
+ suby := y * curh / h
+ r32, g32, b32, a32 := m.At(subx, suby).RGBA()
+ r := uint8(r32 >> 8)
+ g := uint8(g32 >> 8)
+ b := uint8(b32 >> 8)
+ a := uint8(a32 >> 8)
+ img.SetRGBA(x, y, color.RGBA{r, g, b, a})
+ }
+ }
+ return img
+}
diff --git a/Makefile b/Makefile
index 492275eca..0889dab31 100644
--- a/Makefile
+++ b/Makefile
@@ -133,7 +133,7 @@ build: .prebuild prepare-enterprise
build-client:
@echo Building mattermost web app
- cd $(BUILD_WEBAPP_DIR) && make build
+ cd $(BUILD_WEBAPP_DIR) && $(MAKE) build
package: build build-client
@@ -183,10 +183,17 @@ run-server: prepare-enterprise start-docker
run-client:
@echo Running mattermost client for development
- cd $(BUILD_WEBAPP_DIR) && make run
+ cd $(BUILD_WEBAPP_DIR) && $(MAKE) run
+
+run-client-fullmap:
+ @echo Running mattermost client for development with FULL SOURCE MAP
+
+ cd $(BUILD_WEBAPP_DIR) && $(MAKE) run-fullmap
run: run-server run-client
+run-fullmap: run-server run-client-fullmap
+
stop-server:
@echo Stopping mattermost
@@ -203,7 +210,7 @@ stop-server:
stop-client:
@echo Stopping mattermost client
- cd $(BUILD_WEBAPP_DIR) && make stop
+ cd $(BUILD_WEBAPP_DIR) && $(MAKE) stop
stop: stop-server stop-client
@@ -218,7 +225,7 @@ clean: stop-docker
rm -Rf $(DIST_ROOT)
go clean $(GOFLAGS) -i ./...
- cd $(BUILD_WEBAPP_DIR) && make clean
+ cd $(BUILD_WEBAPP_DIR) && $(MAKE) clean
rm -rf api/data
rm -rf logs
diff --git a/README.md b/README.md
index c329df925..2c7d9aaf3 100644
--- a/README.md
+++ b/README.md
@@ -47,5 +47,3 @@ Learn More:
- **Localization Guide** - Learn [how Mattermost supports different languages](http://docs.mattermost.com/developer/localization.html).
Any other questions, mail us at info@mattermost.com. We’d love to meet you!
-
-[![Build Status](https://travis-ci.org/mattermost/platform.svg?branch=master)](https://travis-ci.org/mattermost/platform)
diff --git a/api/api.go b/api/api.go
index 20f77e558..476047877 100644
--- a/api/api.go
+++ b/api/api.go
@@ -27,6 +27,8 @@ func InitApi() {
InitWebhook(r)
InitPreference(r)
InitLicense(r)
+ // 404 on any api route before web.go has a chance to serve it
+ Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404))
utils.InitHTML()
}
diff --git a/api/context.go b/api/context.go
index eed035daf..0f7ba0fff 100644
--- a/api/context.go
+++ b/api/context.go
@@ -476,25 +476,23 @@ func IsPrivateIpAddress(ipAddress string) bool {
}
func RenderWebError(err *model.AppError, w http.ResponseWriter, r *http.Request) {
- T, locale := utils.GetTranslationsAndLocale(w, r)
- page := utils.NewHTMLTemplate("error", locale)
- page.Props["Message"] = err.Message
- page.Props["Details"] = err.DetailedError
-
- pathParts := strings.Split(r.URL.Path, "/")
- if len(pathParts) > 1 {
- page.Props["SiteURL"] = GetProtocol(r) + "://" + r.Host + "/" + pathParts[1]
- } else {
- page.Props["SiteURL"] = GetProtocol(r) + "://" + r.Host
- }
-
- page.Props["Title"] = T("api.templates.error.title", map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]})
- page.Props["Link"] = T("api.templates.error.link")
-
- w.WriteHeader(err.StatusCode)
- if rErr := page.RenderToWriter(w); rErr != nil {
- l4g.Error("Failed to create error page: " + rErr.Error() + ", Original error: " + err.Error())
- }
+ T, _ := utils.GetTranslationsAndLocale(w, r)
+
+ title := T("api.templates.error.title", map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]})
+ message := err.Message
+ details := err.DetailedError
+ link := "/"
+ linkMessage := T("api.templates.error.link")
+
+ http.Redirect(
+ w,
+ r,
+ "/error?title="+url.QueryEscape(title)+
+ "&message="+url.QueryEscape(message)+
+ "&details="+url.QueryEscape(details)+
+ "&link="+url.QueryEscape(link)+
+ "&linkmessage="+url.QueryEscape(linkMessage),
+ http.StatusTemporaryRedirect)
}
func Handle404(w http.ResponseWriter, r *http.Request) {
diff --git a/api/file.go b/api/file.go
index 9150e4bfe..f0873f884 100644
--- a/api/file.go
+++ b/api/file.go
@@ -394,6 +394,11 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
getFileAndForget(path, fileData)
if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 {
+ if !utils.Cfg.FileSettings.EnablePublicLink {
+ c.Err = model.NewLocAppError("getFile", "api.file.get_file.public_disabled.app_error", nil, "")
+ return
+ }
+
if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.FileSettings.PublicLinkSalt)) {
c.Err = model.NewLocAppError("getFile", "api.file.get_file.public_invalid.app_error", nil, "")
return
diff --git a/api/oauth.go b/api/oauth.go
index 9b7f3699d..a7119d7e5 100644
--- a/api/oauth.go
+++ b/api/oauth.go
@@ -29,11 +29,14 @@ func InitOAuth(r *mux.Router) {
sr.Handle("/authorize", ApiUserRequired(authorizeOAuth)).Methods("GET")
sr.Handle("/access_token", ApiAppHandler(getAccessToken)).Methods("POST")
- // Also handle this a the old routes remove soon apiv2?
mr := Srv.Router
mr.Handle("/authorize", ApiUserRequired(authorizeOAuth)).Methods("GET")
mr.Handle("/access_token", ApiAppHandler(getAccessToken)).Methods("POST")
+
+ // Handle all the old routes, to be later removed
mr.Handle("/{service:[A-Za-z]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET")
+ mr.Handle("/signup/{service:[A-Za-z]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET")
+ mr.Handle("/login/{service:[A-Za-z]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET")
}
func registerOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -185,7 +188,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
- uri := c.GetSiteURL() + "/api/v1/oauth/" + service + "/complete"
+ uri := c.GetSiteURL() + "/signup/" + service + "/complete"
if body, team, props, err := AuthorizeOAuthUser(service, code, state, uri); err != nil {
c.Err = err
diff --git a/api/post.go b/api/post.go
index 36fd4ee79..2fe5feb8e 100644
--- a/api/post.go
+++ b/api/post.go
@@ -172,8 +172,6 @@ func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIc
if utils.Cfg.ServiceSettings.EnablePostIconOverride {
if len(overrideIconUrl) != 0 {
post.AddProp("override_icon_url", overrideIconUrl)
- } else {
- post.AddProp("override_icon_url", model.DEFAULT_WEBHOOK_ICON)
}
}
diff --git a/api/user.go b/api/user.go
index 6803a946c..60b92f90d 100644
--- a/api/user.go
+++ b/api/user.go
@@ -53,6 +53,9 @@ func InitUser(r *mux.Router) {
sr.Handle("/attach_device", ApiUserRequired(attachDeviceId)).Methods("POST")
sr.Handle("/verify_email", ApiAppHandler(verifyEmail)).Methods("POST")
sr.Handle("/resend_verification", ApiAppHandler(resendVerification)).Methods("POST")
+ sr.Handle("/mfa", ApiAppHandler(checkMfa)).Methods("POST")
+ sr.Handle("/generate_mfa_qr", ApiUserRequired(generateMfaQrCode)).Methods("GET")
+ sr.Handle("/update_mfa", ApiUserRequired(updateMfa)).Methods("POST")
sr.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST")
@@ -405,13 +408,14 @@ func SendVerifyEmailAndForget(c *Context, userId, userEmail, teamName, teamDispl
}()
}
-func LoginById(c *Context, w http.ResponseWriter, r *http.Request, userId, password, deviceId string) *model.User {
+func LoginById(c *Context, w http.ResponseWriter, r *http.Request, userId, password, mfaToken, deviceId string) *model.User {
if result := <-Srv.Store.User().Get(userId); result.Err != nil {
c.Err = result.Err
return nil
} else {
user := result.Data.(*model.User)
- if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) {
+
+ if authenticateUserPasswordAndToken(c, user, password, mfaToken) {
Login(c, w, r, user, deviceId)
return user
}
@@ -420,7 +424,7 @@ func LoginById(c *Context, w http.ResponseWriter, r *http.Request, userId, passw
return nil
}
-func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, name, password, deviceId string) *model.User {
+func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, name, password, mfaToken, deviceId string) *model.User {
var team *model.Team
if result := <-Srv.Store.Team().GetByName(name); result.Err != nil {
@@ -443,7 +447,7 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam
return nil
}
- if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) {
+ if authenticateUserPasswordAndToken(c, user, password, mfaToken) {
Login(c, w, r, user, deviceId)
return user
}
@@ -452,7 +456,7 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam
return nil
}
-func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, username, name, password, deviceId string) *model.User {
+func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, username, name, password, mfaToken, deviceId string) *model.User {
var team *model.Team
if result := <-Srv.Store.Team().GetByName(name); result.Err != nil {
@@ -475,7 +479,7 @@ func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, usernam
return nil
}
- if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) {
+ if authenticateUserPasswordAndToken(c, user, password, mfaToken) {
Login(c, w, r, user, deviceId)
return user
}
@@ -518,6 +522,10 @@ func LoginByOAuth(c *Context, w http.ResponseWriter, r *http.Request, service st
}
}
+func authenticateUserPasswordAndToken(c *Context, user *model.User, password string, token string) bool {
+ return checkUserLoginAttempts(c, user) && checkUserMfa(c, user, token) && checkUserPassword(c, user, password)
+}
+
func checkUserLoginAttempts(c *Context, user *model.User) bool {
if user.FailedAttempts >= utils.Cfg.ServiceSettings.MaximumLoginAttempts {
c.LogAuditWithUserId(user.Id, "fail")
@@ -530,7 +538,6 @@ func checkUserLoginAttempts(c *Context, user *model.User) bool {
}
func checkUserPassword(c *Context, user *model.User, password string) bool {
-
if !model.ComparePassword(user.Password, password) {
c.LogAuditWithUserId(user.Id, "fail")
c.Err = model.NewLocAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id)
@@ -548,7 +555,29 @@ func checkUserPassword(c *Context, user *model.User, password string) bool {
return true
}
+}
+
+func checkUserMfa(c *Context, user *model.User, token string) bool {
+ if !user.MfaActive || !utils.IsLicensed || !*utils.License.Features.MFA || !*utils.Cfg.ServiceSettings.EnableMultifactorAuthentication {
+ return true
+ }
+ mfaInterface := einterfaces.GetMfaInterface()
+ if mfaInterface == nil {
+ c.Err = model.NewLocAppError("checkUserMfa", "api.user.check_user_mfa.not_available.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return false
+ }
+
+ if ok, err := mfaInterface.ValidateToken(user.MfaSecret, token); err != nil {
+ c.Err = err
+ return false
+ } else if !ok {
+ c.Err = model.NewLocAppError("checkUserMfa", "api.user.check_user_mfa.bad_code.app_error", nil, "")
+ return false
+ } else {
+ return true
+ }
}
// User MUST be validated before calling Login
@@ -660,11 +689,11 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) {
var user *model.User
if len(props["id"]) != 0 {
- user = LoginById(c, w, r, props["id"], props["password"], props["device_id"])
+ user = LoginById(c, w, r, props["id"], props["password"], props["token"], props["device_id"])
} else if len(props["email"]) != 0 && len(props["name"]) != 0 {
- user = LoginByEmail(c, w, r, props["email"], props["name"], props["password"], props["device_id"])
+ user = LoginByEmail(c, w, r, props["email"], props["name"], props["password"], props["token"], props["device_id"])
} else if len(props["username"]) != 0 && len(props["name"]) != 0 {
- user = LoginByUsername(c, w, r, props["username"], props["name"], props["password"], props["device_id"])
+ user = LoginByUsername(c, w, r, props["username"], props["name"], props["password"], props["token"], props["device_id"])
} else {
c.Err = model.NewLocAppError("login", "api.user.login.not_provided.app_error", nil, "")
c.Err.StatusCode = http.StatusForbidden
@@ -695,6 +724,7 @@ func loginLdap(c *Context, w http.ResponseWriter, r *http.Request) {
password := props["password"]
id := props["id"]
teamName := props["teamName"]
+ mfaToken := props["token"]
if len(password) == 0 {
c.Err = model.NewLocAppError("loginLdap", "api.user.login_ldap.blank_pwd.app_error", nil, "")
@@ -735,6 +765,10 @@ func loginLdap(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ if !checkUserMfa(c, user, mfaToken) {
+ return
+ }
+
// User is authenticated at this point
Login(c, w, r, user, props["device_id"])
@@ -1938,7 +1972,7 @@ func GetAuthorizationCode(c *Context, service, teamName string, props map[string
props["team"] = teamName
state := b64.StdEncoding.EncodeToString([]byte(model.MapToJson(props)))
- redirectUri := c.GetSiteURL() + "/api/v1/oauth/" + service + "/complete"
+ redirectUri := c.GetSiteURL() + "/signup/" + service + "/complete"
authUrl := endpoint + "?response_type=code&client_id=" + clientId + "&redirect_uri=" + url.QueryEscape(redirectUri) + "&state=" + url.QueryEscape(state)
@@ -2487,3 +2521,146 @@ func resendVerification(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
}
+
+func generateMfaQrCode(c *Context, w http.ResponseWriter, r *http.Request) {
+ uchan := Srv.Store.User().Get(c.Session.UserId)
+ tchan := Srv.Store.Team().Get(c.Session.TeamId)
+
+ var user *model.User
+ if result := <-uchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ var team *model.Team
+ if result := <-tchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ team = result.Data.(*model.Team)
+ }
+
+ mfaInterface := einterfaces.GetMfaInterface()
+ if mfaInterface == nil {
+ c.Err = model.NewLocAppError("generateMfaQrCode", "api.user.generate_mfa_qr.not_available.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ img, err := mfaInterface.GenerateQrCode(team, user)
+ if err != nil {
+ c.Err = err
+ return
+ }
+
+ w.Header().Del("Content-Type") // Content-Type will be set automatically by the http writer
+ w.Write(img)
+}
+
+func updateMfa(c *Context, w http.ResponseWriter, r *http.Request) {
+ props := model.StringInterfaceFromJson(r.Body)
+
+ activate, ok := props["activate"].(bool)
+ if !ok {
+ c.SetInvalidParam("updateMfa", "activate")
+ return
+ }
+
+ token := ""
+ if activate {
+ token = props["token"].(string)
+ if len(token) == 0 {
+ c.SetInvalidParam("updateMfa", "token")
+ return
+ }
+ }
+
+ mfaInterface := einterfaces.GetMfaInterface()
+ if mfaInterface == nil {
+ c.Err = model.NewLocAppError("generateMfaQrCode", "api.user.update_mfa.not_available.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ if activate {
+ var user *model.User
+ if result := <-Srv.Store.User().Get(c.Session.UserId); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ if err := mfaInterface.Activate(user, token); err != nil {
+ c.Err = err
+ return
+ }
+ } else {
+ if err := mfaInterface.Deactivate(c.Session.UserId); err != nil {
+ c.Err = err
+ return
+ }
+ }
+
+ rdata := map[string]string{}
+ rdata["status"] = "ok"
+ w.Write([]byte(model.MapToJson(rdata)))
+}
+
+func checkMfa(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.IsLicensed || !*utils.License.Features.MFA || !*utils.Cfg.ServiceSettings.EnableMultifactorAuthentication {
+ rdata := map[string]string{}
+ rdata["mfa_required"] = "false"
+ w.Write([]byte(model.MapToJson(rdata)))
+ return
+ }
+
+ props := model.MapFromJson(r.Body)
+
+ method := props["method"]
+ if method != model.USER_AUTH_SERVICE_EMAIL &&
+ method != model.USER_AUTH_SERVICE_USERNAME &&
+ method != model.USER_AUTH_SERVICE_LDAP {
+ c.SetInvalidParam("checkMfa", "method")
+ return
+ }
+
+ teamName := props["team_name"]
+ if len(teamName) == 0 {
+ c.SetInvalidParam("checkMfa", "team_name")
+ return
+ }
+
+ loginId := props["login_id"]
+ if len(loginId) == 0 {
+ c.SetInvalidParam("checkMfa", "login_id")
+ return
+ }
+
+ var team *model.Team
+ if result := <-Srv.Store.Team().GetByName(teamName); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ team = result.Data.(*model.Team)
+ }
+
+ var uchan store.StoreChannel
+ if method == model.USER_AUTH_SERVICE_EMAIL {
+ uchan = Srv.Store.User().GetByEmail(team.Id, loginId)
+ } else if method == model.USER_AUTH_SERVICE_USERNAME {
+ uchan = Srv.Store.User().GetByUsername(team.Id, loginId)
+ } else if method == model.USER_AUTH_SERVICE_LDAP {
+ uchan = Srv.Store.User().GetByAuth(team.Id, loginId, model.USER_AUTH_SERVICE_LDAP)
+ }
+
+ rdata := map[string]string{}
+ if result := <-uchan; result.Err != nil {
+ rdata["mfa_required"] = "false"
+ } else {
+ rdata["mfa_required"] = strconv.FormatBool(result.Data.(*model.User).MfaActive)
+ }
+ w.Write([]byte(model.MapToJson(rdata)))
+}
diff --git a/api/user_test.go b/api/user_test.go
index 86cda0390..33f3fdad4 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -1411,3 +1411,79 @@ func TestMeLoggedIn(t *testing.T) {
}
}
}
+
+func TestGenerateMfaQrCode(t *testing.T) {
+ Setup()
+
+ team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ rteam, _ := Client.CreateTeam(&team)
+
+ user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"}
+ ruser, _ := Client.CreateUser(&user, "")
+ store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id))
+
+ Client.Logout()
+
+ if _, err := Client.GenerateMfaQrCode(); err == nil {
+ t.Fatal("should have failed - not logged in")
+ }
+
+ Client.LoginByEmail(team.Name, user.Email, user.Password)
+
+ if _, err := Client.GenerateMfaQrCode(); err == nil {
+ t.Fatal("should have failed - not licensed")
+ }
+
+ // need to add more test cases when license and config can be configured for tests
+}
+
+func TestUpdateMfa(t *testing.T) {
+ Setup()
+
+ team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ rteam, _ := Client.CreateTeam(&team)
+
+ user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"}
+ ruser, _ := Client.CreateUser(&user, "")
+ store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id))
+
+ Client.Logout()
+
+ if _, err := Client.UpdateMfa(true, "123456"); err == nil {
+ t.Fatal("should have failed - not logged in")
+ }
+
+ Client.LoginByEmail(team.Name, user.Email, user.Password)
+
+ if _, err := Client.UpdateMfa(true, ""); err == nil {
+ t.Fatal("should have failed - no token")
+ }
+
+ if _, err := Client.UpdateMfa(true, "123456"); err == nil {
+ t.Fatal("should have failed - not licensed")
+ }
+
+ // need to add more test cases when license and config can be configured for tests
+}
+
+func TestCheckMfa(t *testing.T) {
+ Setup()
+
+ team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ rteam, _ := Client.CreateTeam(&team)
+
+ user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"}
+ ruser, _ := Client.CreateUser(&user, "")
+ store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id))
+
+ if result, err := Client.CheckMfa(model.USER_AUTH_SERVICE_EMAIL, team.Name, user.Email); err != nil {
+ t.Fatal(err)
+ } else {
+ resp := result.Data.(map[string]string)
+ if resp["mfa_required"] != "false" {
+ t.Fatal("mfa should not be required")
+ }
+ }
+
+ // need to add more test cases when license and config can be configured for tests
+}
diff --git a/config/config.json b/config/config.json
index 65a61bb72..62dcfcffc 100644
--- a/config/config.json
+++ b/config/config.json
@@ -15,6 +15,7 @@
"EnableDeveloper": false,
"EnableSecurityFixAlert": true,
"EnableInsecureOutgoingConnections": false,
+ "EnableMultifactorAuthentication": false,
"AllowCorsFrom": "",
"SessionLengthWebInDays": 30,
"SessionLengthMobileInDays": 30,
diff --git a/einterfaces/mfa.go b/einterfaces/mfa.go
new file mode 100644
index 000000000..0703fb766
--- /dev/null
+++ b/einterfaces/mfa.go
@@ -0,0 +1,25 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package einterfaces
+
+import (
+ "github.com/mattermost/platform/model"
+)
+
+type MfaInterface interface {
+ GenerateQrCode(team *model.Team, user *model.User) ([]byte, *model.AppError)
+ Activate(user *model.User, token string) *model.AppError
+ Deactivate(userId string) *model.AppError
+ ValidateToken(secret, token string) (bool, *model.AppError)
+}
+
+var theMfaInterface MfaInterface
+
+func RegisterMfaInterface(newInterface MfaInterface) {
+ theMfaInterface = newInterface
+}
+
+func GetMfaInterface() MfaInterface {
+ return theMfaInterface
+}
diff --git a/i18n/en.json b/i18n/en.json
index 59d83235d..6292c1e03 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -508,6 +508,10 @@
"translation": "Could not find file."
},
{
+ "id": "api.file.get_file.public_disabled.app_error",
+ "translation": "Public links have been disabled by the system administrator"
+ },
+ {
"id": "api.file.get_file.public_invalid.app_error",
"translation": "The public link does not appear to be valid"
},
@@ -729,11 +733,11 @@
},
{
"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."
+ "translation": "{{.Usernames}} and {{.LastUsername}} were mentioned, but they did not receive notifications because they do not belong to this channel."
},
{
"id": "api.post.check_for_out_of_channel_mentions.message.one",
- "translation": "{{.Username}} was mentioned, but does not belong to this channel."
+ "translation": "{{.Username}} was mentioned, but they did not receive a notification because they do not belong to this channel."
},
{
"id": "api.post.create_post.bad_filename.error",
@@ -1364,6 +1368,14 @@
"translation": "Your account is locked because of too many failed password attempts. Please reset your password."
},
{
+ "id": "api.user.check_user_mfa.bad_code.app_error",
+ "translation": "Invalid MFA token."
+ },
+ {
+ "id": "api.user.check_user_mfa.not_available.app_error",
+ "translation": "MFA is not configured or supported on this server"
+ },
+ {
"id": "api.user.check_user_password.invalid.app_error",
"translation": "Login failed because of invalid password"
},
@@ -1448,6 +1460,10 @@
"translation": "LDAP not available on this server"
},
{
+ "id": "api.user.generate_mfa_qr.not_available.app_error",
+ "translation": "MFA not configured or available on this server"
+ },
+ {
"id": "api.user.get_authorization_code.unsupported.app_error",
"translation": "Unsupported OAuth service provider"
},
@@ -1465,7 +1481,7 @@
},
{
"id": "api.user.ldap_to_email.not_ldap_account.app_error",
- "translation": "This user account does use LDAP"
+ "translation": "This user account does not use LDAP"
},
{
"id": "api.user.login.blank_pwd.app_error",
@@ -1592,6 +1608,10 @@
"translation": "You do not have the appropriate permissions"
},
{
+ "id": "api.user.update_mfa.not_available.app_error",
+ "translation": "MFA not configured or available on this server"
+ },
+ {
"id": "api.user.update_password.context.app_error",
"translation": "Update password failed because context user_id did not match props user_id"
},
@@ -1804,6 +1824,42 @@
"translation": "User not registered on LDAP server"
},
{
+ "id": "ent.mfa.activate.authenticate.app_error",
+ "translation": "Error attempting to authenticate MFA token"
+ },
+ {
+ "id": "ent.mfa.activate.bad_token.app_error",
+ "translation": "Invalid MFA token"
+ },
+ {
+ "id": "ent.mfa.activate.save_active.app_erro",
+ "translation": "Unable to update MFA active status for the user"
+ },
+ {
+ "id": "ent.mfa.deactivate.save_active.app_erro",
+ "translation": "Unable to update MFA active status for the user"
+ },
+ {
+ "id": "ent.mfa.deactivate.save_secret.app_error",
+ "translation": "Error clearing the MFA secret"
+ },
+ {
+ "id": "ent.mfa.generate_qr_code.create_code.app_error",
+ "translation": "Error generating QR code"
+ },
+ {
+ "id": "ent.mfa.generate_qr_code.save_secret.app_error",
+ "translation": "Error saving the MFA secret"
+ },
+ {
+ "id": "ent.mfa.license_disable.app_error",
+ "translation": "Your license does not support using multi-factor authentication"
+ },
+ {
+ "id": "ent.mfa.validate_token.authenticate.app_error",
+ "translation": "Error trying to authenticate MFA token"
+ },
+ {
"id": "manaultesting.get_channel_id.no_found.debug",
"translation": "Could not find channel: %v, %v possibilites searched"
},
@@ -3292,6 +3348,14 @@
"translation": "We couldn't update the last_ping_at"
},
{
+ "id": "store.sql_user.update_mfa_active.app_error",
+ "translation": "We encountered an error updating the user's MFA active status"
+ },
+ {
+ "id": "store.sql_user.update_mfa_secret.app_error",
+ "translation": "We encountered an error updating the user's MFA secret"
+ },
+ {
"id": "store.sql_user.update_password.app_error",
"translation": "We couldn't update the user password"
},
@@ -3706,5 +3770,29 @@
{
"id": "web.watcher_fail.error",
"translation": "Failed to add directory to watcher %v"
+ },
+ {
+ "id": "error.not_found.title",
+ "translation": "Page not found"
+ },
+ {
+ "id": "error.not_found.message",
+ "translation": "The page you where trying to reach does not exist."
+ },
+ {
+ "id": "error.not_found.link_message",
+ "translation": "Back to Mattermost"
+ },
+ {
+ "id": "error.generic.title",
+ "translation": "Error"
+ },
+ {
+ "id": "error.generic.message",
+ "translation": "An error has occoured."
+ },
+ {
+ "id": "error.generic.link_message",
+ "translation": "Back to Mattermost"
}
]
diff --git a/i18n/es.json b/i18n/es.json
index 06a725e18..52654f4af 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -476,6 +476,10 @@
"translation": "No se encontrΓ³ el archivo."
},
{
+ "id": "api.file.get_file.public_disabled.app_error",
+ "translation": "Los enlaces pΓΊblicos han sido deshabilitados por un administrador del sistema"
+ },
+ {
"id": "api.file.get_file.public_invalid.app_error",
"translation": "El enlace pΓΊblico parece ser invΓ‘lido"
},
@@ -697,11 +701,11 @@
},
{
"id": "api.post.check_for_out_of_channel_mentions.message.multiple",
- "translation": "{{.Usernames}} y {{.LastUsername}} fueron mencionados, pero no pertenecen a este canal."
+ "translation": "{{.Usernames}} y {{.LastUsername}} fueron mencionados, pero no recibieron una notificaciΓ³n porque no pertenecen a este canal."
},
{
"id": "api.post.check_for_out_of_channel_mentions.message.one",
- "translation": "{{.Username}} fue mencionado, pero no pertenece a este canal."
+ "translation": "{{.Username}} fue mencionado, pero no recibiΓ³ una notificaciΓ³n porque no pertenece a este canal."
},
{
"id": "api.post.create_post.bad_filename.error",
@@ -1332,6 +1336,14 @@
"translation": "Tu cuenta ha sido bloqueada debido a demasiados intentos fallidos. Por favor, restablece tu contraseΓ±a."
},
{
+ "id": "api.user.check_user_mfa.bad_code.app_error",
+ "translation": "Token AMF invΓ‘lido."
+ },
+ {
+ "id": "api.user.check_user_mfa.not_available.app_error",
+ "translation": "AMF on estΓ‘ configurado o no es soportado en este servidor"
+ },
+ {
"id": "api.user.check_user_password.invalid.app_error",
"translation": "El inicio de sesiΓ³n fallΓ³ porque la contraseΓ±a es invΓ‘lida"
},
@@ -1416,6 +1428,10 @@
"translation": "LDAP no estΓ‘ disponible en este servidor"
},
{
+ "id": "api.user.generate_mfa_qr.not_available.app_error",
+ "translation": "AMF no estΓ‘ configurado o disponible en este servidor"
+ },
+ {
"id": "api.user.get_authorization_code.unsupported.app_error",
"translation": "Proveedor de servicios de OAuth no es compatible"
},
@@ -1432,10 +1448,6 @@
"translation": "LDAP no estΓ‘ disponible en este servidor"
},
{
- "id": "api.user.ldap_to_email.not_ldap_account.app_error",
- "translation": "La cuenta de este usuario utiliza LDAP"
- },
- {
"id": "api.user.login.blank_pwd.app_error",
"translation": "El campo de contraseΓ±a no debe quedar en blanco"
},
@@ -1560,6 +1572,10 @@
"translation": "No tienes los permisos apropiados"
},
{
+ "id": "api.user.update_mfa.not_available.app_error",
+ "translation": "AMF no estΓ‘ configurado o disponible en este servidor"
+ },
+ {
"id": "api.user.update_password.context.app_error",
"translation": "La actualizaciΓ³n de la contraseΓ±a fallΓ³ debido a que el user_id del contexto no coincide con el user_id de los props"
},
@@ -1772,6 +1788,42 @@
"translation": "Usuario no registrado en el servidor LDAP"
},
{
+ "id": "ent.mfa.activate.authenticate.app_error",
+ "translation": "Error intentando autenticar el token AMF"
+ },
+ {
+ "id": "ent.mfa.activate.bad_token.app_error",
+ "translation": "Token AMF invΓ‘lido"
+ },
+ {
+ "id": "ent.mfa.activate.save_active.app_erro",
+ "translation": "No se pudo actualizar el estado activo AMF para el usuario"
+ },
+ {
+ "id": "ent.mfa.deactivate.save_active.app_erro",
+ "translation": "No se pudo actualizar el estado activo AMF para el usuario"
+ },
+ {
+ "id": "ent.mfa.deactivate.save_secret.app_error",
+ "translation": "Error al limpiar el secreto AMF"
+ },
+ {
+ "id": "ent.mfa.generate_qr_code.create_code.app_error",
+ "translation": "Error generando el cΓ³digo QR"
+ },
+ {
+ "id": "ent.mfa.generate_qr_code.save_secret.app_error",
+ "translation": "Error guardando el secreto AMF"
+ },
+ {
+ "id": "ent.mfa.license_disable.app_error",
+ "translation": "Tu licencia no soporta la autenticaciΓ³n de mΓΊltiples factores"
+ },
+ {
+ "id": "ent.mfa.validate_token.authenticate.app_error",
+ "translation": "Error intentando autenticar el token AMF"
+ },
+ {
"id": "manaultesting.get_channel_id.no_found.debug",
"translation": "No pudimos encontrar el canal: %v, bΓΊsqueda realizada con estas posibilidades %v"
},
@@ -3256,6 +3308,14 @@
"translation": "No pudimos actualizar el campo last_ping_at"
},
{
+ "id": "store.sql_user.update_mfa_active.app_error",
+ "translation": "Encontramos un error al actualizar el estado activo AMF del usuario"
+ },
+ {
+ "id": "store.sql_user.update_mfa_secret.app_error",
+ "translation": "Encontramos un error al actualizar el secreto AMF del usuario"
+ },
+ {
"id": "store.sql_user.update_password.app_error",
"translation": "No pudimos actualizar la contraseΓ±a del usuario"
},
diff --git a/i18n/fr.json b/i18n/fr.json
index 300e1c0de..984c1ef93 100644
--- a/i18n/fr.json
+++ b/i18n/fr.json
@@ -696,14 +696,6 @@
"translation": "Erreur lors de la récupération du jeton d'accès avant suppression"
},
{
- "id": "api.post.check_for_out_of_channel_mentions.message.multiple",
- "translation": "{{.Usernames}} et {{.LastUsername}} sont mentionnΓ©s, mais ne participent pas Γ  ce canal."
- },
- {
- "id": "api.post.check_for_out_of_channel_mentions.message.one",
- "translation": "{{.Username}} a Γ©tΓ© mentionnΓ©, mais ne participe pas Γ  ce canal."
- },
- {
"id": "api.post.create_post.bad_filename.error",
"translation": "Nom de fichier invalide supprimΓ©, filename=%v"
},
@@ -1548,10 +1540,6 @@
"translation": "LDAP n'est pas disponible sur ce serveur"
},
{
- "id": "api.user.ldap_to_email.not_ldap_account.app_error",
- "translation": "Ce compte utilisateur utilise LDAP"
- },
- {
"id": "api.user.ldap_to_email.not_available.app_error",
"translation": "LDAP n'est pas disponible sur ce serveur"
},
@@ -3671,4 +3659,4 @@
"id": "web.watcher_fail.error",
"translation": "Γ‰chec de l'ajout du dossier Γ  l'observateur %v"
}
-] \ No newline at end of file
+]
diff --git a/i18n/pt.json b/i18n/pt.json
index 6bcf9e072..631198e1f 100644
--- a/i18n/pt.json
+++ b/i18n/pt.json
@@ -476,6 +476,10 @@
"translation": "NΓ£o foi possΓ­vel encontrar o arquivo."
},
{
+ "id": "api.file.get_file.public_disabled.app_error",
+ "translation": "Public links have been disabled by the system administrator"
+ },
+ {
"id": "api.file.get_file.public_invalid.app_error",
"translation": "O link pΓΊblico nΓ£o parece ser vΓ‘lido"
},
@@ -697,11 +701,11 @@
},
{
"id": "api.post.check_for_out_of_channel_mentions.message.multiple",
- "translation": "{{.Usernames}} e {{.LastUsername}} foram mencionados, mas eles nΓ£o pertencem a este canal."
+ "translation": "{{.Usernames}} e {{.LastUsername}} foram mencionados, mas eles não receberam notificação porque eles não pertencem a este canal."
},
{
"id": "api.post.check_for_out_of_channel_mentions.message.one",
- "translation": "{{.Username}} foi mencionado, mas ele nΓ£o pertence a este canal."
+ "translation": "{{.Username}} foi mencionado, mas eles não receberam uma notificação porque eles não pertencem a este canal."
},
{
"id": "api.post.create_post.bad_filename.error",
@@ -1292,6 +1296,14 @@
"translation": "VocΓͺ se juntou {{ .TeamDisplayName }}"
},
{
+ "id": "api.user.update_mfa.not_available.app_error",
+ "translation": "MFA nΓ£o configurado ou disponΓ­vel neste servidor"
+ },
+ {
+ "id": "api.user.generate_mfa_qr.not_available.app_error",
+ "translation": "MFA nΓ£o configurado ou disponΓ­vel neste servidor"
+ },
+ {
"id": "api.user.add_direct_channels_and_forget.failed.error",
"translation": "Falha ao adicionar preferencias diretas ao canal para o usuΓ‘rio user_id=%s, team_id=%s, err=%v"
},
@@ -1328,6 +1340,14 @@
"translation": "Provedor de serviço OAuth não suportado"
},
{
+ "id": "api.user.check_user_mfa.not_available.app_error",
+ "translation": "MFA nΓ£o configurado ou disponΓ­vel neste servidor"
+ },
+ {
+ "id": "api.user.check_user_mfa.bad_code.app_error",
+ "translation": "Token MFA invΓ‘lido."
+ },
+ {
"id": "api.user.check_user_login_attempts.too_many.app_error",
"translation": "A sua conta estΓ‘ bloqueada por causa de muitas tentativas de senha que falharam. Por favor, redefina sua senha."
},
@@ -1412,6 +1432,10 @@
"translation": "Falha ao definir email como verificado err=%v"
},
{
+ "id": "api.user.email_to_ldap.not_available.app_error",
+ "translation": "LDAP nΓ£o estΓ‘ disponΓ­vel neste servidor"
+ },
+ {
"id": "api.user.get_authorization_code.unsupported.app_error",
"translation": "Provedor de serviço OAuth não suportado"
},
@@ -1424,6 +1448,10 @@
"translation": "Inicializando user api routes"
},
{
+ "id": "api.user.ldap_to_email.not_available.app_error",
+ "translation": "LDAP nΓ£o estΓ‘ disponΓ­vel neste servidor"
+ },
+ {
"id": "api.user.login.blank_pwd.app_error",
"translation": "Campo senha nΓ£o pode estar em branco"
},
@@ -1472,6 +1500,10 @@
"translation": "LDAP nΓ£o estΓ‘ disponΓ­vel neste servidor"
},
{
+ "id": "api.user.oauth_to_email.context.app_error",
+ "translation": "Atualização de senha falhou devido ao contexto user_id não combinar com id de usuÑrio fornecido"
+ },
+ {
"id": "api.user.permanent_delete_user.attempting.warn",
"translation": "Tentando permanentemente deletar a conta %v id=%v"
},
@@ -1540,10 +1572,6 @@
"translation": "Falha ao enviar com sucesso boas vindas por email err=%v"
},
{
- "id": "api.user.oauth_to_email.context.app_error",
- "translation": "Atualização de senha falhou devido ao contexto user_id não combinar com id de usuÑrio fornecido"
- },
- {
"id": "api.user.update_active.permissions.app_error",
"translation": "VocΓͺ nΓ£o tem a permissΓ£o apropriada"
},
@@ -1728,6 +1756,42 @@
"translation": "Exportação de compliance tarefa '{{.JobName}}' iniciada no '{{.FilePath}}'"
},
{
+ "id": "ent.mfa.license_disable.app_error",
+ "translation": "Sua licença não suporta o uso de autenticação multi-fator"
+ },
+ {
+ "id": "ent.mfa.generate_qr_code.create_code.app_error",
+ "translation": "Erro ao gerar QR code"
+ },
+ {
+ "id": "ent.mfa.generate_qr_code.save_secret.app_error",
+ "translation": "Erro ao salvar o segredo MFA"
+ },
+ {
+ "id": "ent.mfa.activate.authenticate.app_error",
+ "translation": "Erro ao tentar autenticar o token MFA"
+ },
+ {
+ "id": "ent.mfa.activate.bad_token.app_error",
+ "translation": "Token MFA invΓ‘lido"
+ },
+ {
+ "id": "ent.mfa.activate.save_active.app_erro",
+ "translation": "NΓ£o foi possΓ­vel atualizar o status ativo MFA para o usuΓ‘rio"
+ },
+ {
+ "id": "ent.mfa.deactivate.save_active.app_erro",
+ "translation": "NΓ£o foi possΓ­vel atualizar o status ativo MFA para o usuΓ‘rio"
+ },
+ {
+ "id": "ent.mfa.deactivate.save_secret.app_error",
+ "translation": "Erro ao limpar o segredo MFA"
+ },
+ {
+ "id": "ent.mfa.validate_token.authenticate.app_error",
+ "translation": "Erro ao tentar autenticar o token MFA"
+ },
+ {
"id": "ent.ldap.do_login.bind_admin_user.app_error",
"translation": "NΓ£o foi possΓ­vel ligar ao servidor LDAP. Verifique BindUsername e BindPassword."
},
@@ -3124,6 +3188,14 @@
"translation": "NΓ£o foi possΓ­vel atualizar o nome da equipe"
},
{
+ "id": "store.sql_user.update_mfa_secret.app_error",
+ "translation": "Foi encontrado um erro ao atualizar o segredo MFA do usuΓ‘rio"
+ },
+ {
+ "id": "store.sql_user.update_mfa_active.app_error",
+ "translation": "Encontramos um erro ao atualizar o status ativo MFA do usuΓ‘rio"
+ },
+ {
"id": "store.sql_user.analytics_unique_user_count.app_error",
"translation": "NΓ£o foi possΓ­vel obter o nΓΊmero de usuΓ‘rios ΓΊnicos"
},
diff --git a/mattermost.go b/mattermost.go
index c555862e9..d397a1ad8 100644
--- a/mattermost.go
+++ b/mattermost.go
@@ -29,7 +29,9 @@ import (
_ "github.com/mattermost/platform/model/gitlab"
// Enterprise Deps
+ _ "github.com/dgryski/dgoogauth"
_ "github.com/go-ldap/ldap"
+ _ "github.com/mattermost/rsc/qr"
)
//ENTERPRISE_IMPORTS
diff --git a/model/client.go b/model/client.go
index ee26ae64e..960fe634b 100644
--- a/model/client.go
+++ b/model/client.go
@@ -301,6 +301,42 @@ func (c *Client) Logout() (*Result, *AppError) {
}
}
+func (c *Client) CheckMfa(method, teamName, loginId string) (*Result, *AppError) {
+ m := make(map[string]string)
+ m["method"] = method
+ m["team_name"] = teamName
+ m["login_id"] = loginId
+
+ if r, err := c.DoApiPost("/users/mfa", MapToJson(m)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) GenerateMfaQrCode() (*Result, *AppError) {
+ if r, err := c.DoApiGet("/users/generate_mfa_qr", "", ""); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), r.Body}, nil
+ }
+}
+
+func (c *Client) UpdateMfa(activate bool, token string) (*Result, *AppError) {
+ m := make(map[string]interface{})
+ m["activate"] = activate
+ m["token"] = token
+
+ if r, err := c.DoApiPost("/users/update_mfa", StringInterfaceToJson(m)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) SetOAuthToken(token string) {
c.AuthToken = token
c.AuthType = HEADER_TOKEN
diff --git a/model/config.go b/model/config.go
index 3ca241275..e7ab07f8c 100644
--- a/model/config.go
+++ b/model/config.go
@@ -46,6 +46,7 @@ type ServiceSettings struct {
EnableDeveloper *bool
EnableSecurityFixAlert *bool
EnableInsecureOutgoingConnections *bool
+ EnableMultifactorAuthentication *bool
AllowCorsFrom *string
SessionLengthWebInDays *int
SessionLengthMobileInDays *int
@@ -275,6 +276,11 @@ func (o *Config) SetDefaults() {
*o.ServiceSettings.EnableInsecureOutgoingConnections = false
}
+ if o.ServiceSettings.EnableMultifactorAuthentication == nil {
+ o.ServiceSettings.EnableMultifactorAuthentication = new(bool)
+ *o.ServiceSettings.EnableMultifactorAuthentication = false
+ }
+
if o.TeamSettings.RestrictTeamNames == nil {
o.TeamSettings.RestrictTeamNames = new(bool)
*o.TeamSettings.RestrictTeamNames = true
diff --git a/model/license.go b/model/license.go
index 8461c9f76..cab22a685 100644
--- a/model/license.go
+++ b/model/license.go
@@ -34,6 +34,7 @@ type Customer struct {
type Features struct {
Users *int `json:"users"`
LDAP *bool `json:"ldap"`
+ MFA *bool `json:"mfa"`
GoogleSSO *bool `json:"google_sso"`
Compliance *bool `json:"compliance"`
}
@@ -49,6 +50,11 @@ func (f *Features) SetDefaults() {
*f.LDAP = true
}
+ if f.MFA == nil {
+ f.MFA = new(bool)
+ *f.MFA = true
+ }
+
if f.GoogleSSO == nil {
f.GoogleSSO = new(bool)
*f.GoogleSSO = true
diff --git a/model/team.go b/model/team.go
index bed7bbd8d..d95dea110 100644
--- a/model/team.go
+++ b/model/team.go
@@ -237,5 +237,7 @@ func (o *Team) SanitizeForNotLoggedIn() {
o.Email = ""
o.AllowedDomains = ""
o.CompanyName = ""
- o.InviteId = ""
+ if !o.AllowOpenInvite {
+ o.InviteId = ""
+ }
}
diff --git a/model/user.go b/model/user.go
index 675a1ded6..173fe2b4e 100644
--- a/model/user.go
+++ b/model/user.go
@@ -15,17 +15,19 @@ import (
)
const (
- ROLE_TEAM_ADMIN = "admin"
- ROLE_SYSTEM_ADMIN = "system_admin"
- USER_AWAY_TIMEOUT = 5 * 60 * 1000 // 5 minutes
- USER_OFFLINE_TIMEOUT = 1 * 60 * 1000 // 1 minute
- USER_OFFLINE = "offline"
- USER_AWAY = "away"
- USER_ONLINE = "online"
- USER_NOTIFY_ALL = "all"
- USER_NOTIFY_MENTION = "mention"
- USER_NOTIFY_NONE = "none"
- DEFAULT_LOCALE = "en"
+ ROLE_TEAM_ADMIN = "admin"
+ ROLE_SYSTEM_ADMIN = "system_admin"
+ USER_AWAY_TIMEOUT = 5 * 60 * 1000 // 5 minutes
+ USER_OFFLINE_TIMEOUT = 1 * 60 * 1000 // 1 minute
+ USER_OFFLINE = "offline"
+ USER_AWAY = "away"
+ USER_ONLINE = "online"
+ USER_NOTIFY_ALL = "all"
+ USER_NOTIFY_MENTION = "mention"
+ USER_NOTIFY_NONE = "none"
+ DEFAULT_LOCALE = "en"
+ USER_AUTH_SERVICE_EMAIL = "email"
+ USER_AUTH_SERVICE_USERNAME = "username"
)
type User struct {
@@ -54,6 +56,8 @@ type User struct {
LastPictureUpdate int64 `json:"last_picture_update,omitempty"`
FailedAttempts int `json:"failed_attempts,omitempty"`
Locale string `json:"locale"`
+ MfaActive bool `json:"mfa_active,omitempty"`
+ MfaSecret string `json:"mfa_secret,omitempty"`
}
// IsValid validates the user and returns an error if it isn't configured
@@ -140,6 +144,8 @@ func (u *User) PreSave() {
u.LastPasswordUpdate = u.CreateAt
+ u.MfaActive = false
+
if u.Locale == "" {
u.Locale = DEFAULT_LOCALE
}
@@ -346,7 +352,14 @@ func IsInRole(userRoles string, inRole string) bool {
}
func (u *User) IsSSOUser() bool {
- if len(u.AuthData) != 0 && len(u.AuthService) != 0 {
+ if len(u.AuthData) != 0 && len(u.AuthService) != 0 && u.AuthService != USER_AUTH_SERVICE_LDAP {
+ return true
+ }
+ return false
+}
+
+func (u *User) IsLDAPUser() bool {
+ if u.AuthService == USER_AUTH_SERVICE_LDAP {
return true
}
return false
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 6062b8a6a..957921b9e 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -40,6 +40,7 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore {
table.ColMap("NotifyProps").SetMaxSize(2000)
table.ColMap("ThemeProps").SetMaxSize(2000)
table.ColMap("Locale").SetMaxSize(5)
+ table.ColMap("MfaSecret").SetMaxSize(128)
table.SetUniqueTogether("Email", "TeamId")
table.SetUniqueTogether("Username", "TeamId")
}
@@ -50,6 +51,9 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore {
func (us SqlUserStore) UpgradeSchemaIfNeeded() {
// ADDED for 2.0 REMOVE for 2.4
us.CreateColumnIfNotExists("Users", "Locale", "varchar(5)", "character varying(5)", model.DEFAULT_LOCALE)
+ // ADDED for 2.2 REMOVE for 2.6
+ us.CreateColumnIfNotExists("Users", "MfaActive", "tinyint(1)", "boolean", "0")
+ us.CreateColumnIfNotExists("Users", "MfaSecret", "varchar(128)", "character varying(128)", "")
}
func (us SqlUserStore) CreateIndexesIfNotExists() {
@@ -141,6 +145,8 @@ func (us SqlUserStore) Update(user *model.User, allowActiveUpdate bool) StoreCha
user.LastPingAt = oldUser.LastPingAt
user.EmailVerified = oldUser.EmailVerified
user.FailedAttempts = oldUser.FailedAttempts
+ user.MfaSecret = oldUser.MfaSecret
+ user.MfaActive = oldUser.MfaActive
if !allowActiveUpdate {
user.Roles = oldUser.Roles
@@ -149,7 +155,7 @@ func (us SqlUserStore) Update(user *model.User, allowActiveUpdate bool) StoreCha
if user.IsSSOUser() {
user.Email = oldUser.Email
- } else if user.Email != oldUser.Email {
+ } else if !user.IsLDAPUser() && user.Email != oldUser.Email {
user.EmailVerified = false
}
@@ -346,6 +352,50 @@ func (us SqlUserStore) UpdateAuthData(userId, service, authData, email string) S
return storeChannel
}
+func (us SqlUserStore) UpdateMfaSecret(userId, secret string) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ updateAt := model.GetMillis()
+
+ if _, err := us.GetMaster().Exec("UPDATE Users SET MfaSecret = :Secret, UpdateAt = :UpdateAt WHERE Id = :UserId", map[string]interface{}{"Secret": secret, "UpdateAt": updateAt, "UserId": userId}); err != nil {
+ result.Err = model.NewLocAppError("SqlUserStore.UpdateMfaSecret", "store.sql_user.update_mfa_secret.app_error", nil, "id="+userId+", "+err.Error())
+ } else {
+ result.Data = userId
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (us SqlUserStore) UpdateMfaActive(userId string, active bool) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ updateAt := model.GetMillis()
+
+ if _, err := us.GetMaster().Exec("UPDATE Users SET MfaActive = :Active, UpdateAt = :UpdateAt WHERE Id = :UserId", map[string]interface{}{"Active": active, "UpdateAt": updateAt, "UserId": userId}); err != nil {
+ result.Err = model.NewLocAppError("SqlUserStore.UpdateMfaActive", "store.sql_user.update_mfa_active.app_error", nil, "id="+userId+", "+err.Error())
+ } else {
+ result.Data = userId
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (us SqlUserStore) Get(id string) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go
index 8f2366136..dcd2440ac 100644
--- a/store/sql_user_store_test.go
+++ b/store/sql_user_store_test.go
@@ -502,3 +502,47 @@ func TestUserUnreadCount(t *testing.T) {
t.Fatal("should have 3 unread messages")
}
}
+
+func TestUserStoreUpdateMfaSecret(t *testing.T) {
+ Setup()
+
+ u1 := model.User{}
+ u1.TeamId = model.NewId()
+ u1.Email = model.NewId()
+ Must(store.User().Save(&u1))
+
+ time.Sleep(100 * time.Millisecond)
+
+ if err := (<-store.User().UpdateMfaSecret(u1.Id, "12345")).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ // should pass, no update will occur though
+ if err := (<-store.User().UpdateMfaSecret("junk", "12345")).Err; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestUserStoreUpdateMfaActive(t *testing.T) {
+ Setup()
+
+ u1 := model.User{}
+ u1.TeamId = model.NewId()
+ u1.Email = model.NewId()
+ Must(store.User().Save(&u1))
+
+ time.Sleep(100 * time.Millisecond)
+
+ if err := (<-store.User().UpdateMfaActive(u1.Id, true)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ if err := (<-store.User().UpdateMfaActive(u1.Id, false)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ // should pass, no update will occur though
+ if err := (<-store.User().UpdateMfaActive("junk", true)).Err; err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/store/store.go b/store/store.go
index 7ec5ac3a5..323595ffb 100644
--- a/store/store.go
+++ b/store/store.go
@@ -117,6 +117,8 @@ type UserStore interface {
UpdateUserAndSessionActivity(userId string, sessionId string, time int64) StoreChannel
UpdatePassword(userId, newPassword string) StoreChannel
UpdateAuthData(userId, service, authData, email string) StoreChannel
+ UpdateMfaSecret(userId, secret string) StoreChannel
+ UpdateMfaActive(userId string, active bool) StoreChannel
Get(id string) StoreChannel
GetProfiles(teamId string) StoreChannel
GetByEmail(teamId string, email string) StoreChannel
diff --git a/templates/error.html b/templates/error.html
deleted file mode 100644
index 5aa48098f..000000000
--- a/templates/error.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{{define "error"}}
-<!DOCTYPE html>
-<html>
-{{template "head" . }}
-<body class="sticky error">
- <div class="container-fluid">
- <div class="error__container">
- <div class="error__icon">
- <i class="fa fa-exclamation-triangle"/>
- </div>
- <h2>{{.Props.Title}}</h2>
- <p>{{ .Props.Message }}</p>
- <a href="{{.Props.SiteURL}}">{{.Props.Link}}</a>
- </div>
- </div>
-</body>
-<script>
-var details = {{ .Props.Details }};
-if (details.length > 0) {
- console.log("error details: " + details);
-}
-</script>
-</html>
-{{end}}
diff --git a/utils/config.go b/utils/config.go
index 9624196be..93c8ffc7c 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -212,6 +212,7 @@ func getClientConfig(c *model.Config) map[string]string {
props["EnableSignUpWithEmail"] = strconv.FormatBool(c.EmailSettings.EnableSignUpWithEmail)
props["EnableSignInWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithEmail)
props["EnableSignInWithUsername"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithUsername)
+ props["EnableMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnableMultifactorAuthentication)
props["RequireEmailVerification"] = strconv.FormatBool(c.EmailSettings.RequireEmailVerification)
props["FeedbackEmail"] = c.EmailSettings.FeedbackEmail
diff --git a/utils/license.go b/utils/license.go
index 1dc8bf025..217fd27ce 100644
--- a/utils/license.go
+++ b/utils/license.go
@@ -114,6 +114,7 @@ func getClientLicense(l *model.License) map[string]string {
if IsLicensed {
props["Users"] = strconv.Itoa(*l.Features.Users)
props["LDAP"] = strconv.FormatBool(*l.Features.LDAP)
+ props["MFA"] = strconv.FormatBool(*l.Features.MFA)
props["GoogleSSO"] = strconv.FormatBool(*l.Features.GoogleSSO)
props["Compliance"] = strconv.FormatBool(*l.Features.Compliance)
props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10)
diff --git a/webapp/Makefile b/webapp/Makefile
index 4cc9be1d3..6ec75d1df 100644
--- a/webapp/Makefile
+++ b/webapp/Makefile
@@ -22,6 +22,11 @@ run: .npminstall
npm run run &
+run-fullmap: .npminstall
+ @echo FULL SOURCE MAP Running mattermost Webapp for development FULL SOURCE MAP
+
+ npm run run-fullmap &
+
stop:
@echo Stopping changes watching
diff --git a/webapp/action_creators/global_actions.jsx b/webapp/action_creators/global_actions.jsx
index ab38532a6..9c38d8955 100644
--- a/webapp/action_creators/global_actions.jsx
+++ b/webapp/action_creators/global_actions.jsx
@@ -13,23 +13,56 @@ import * as Utils from 'utils/utils.jsx';
import * as Websockets from './websocket_actions.jsx';
import * as I18n from 'i18n/i18n.jsx';
+import {browserHistory} from 'react-router';
+
import en from 'i18n/en.json';
export function emitChannelClickEvent(channel) {
- AsyncClient.getChannels(true);
- AsyncClient.getChannelExtraInfo(channel.id);
- AsyncClient.updateLastViewedAt(channel.id);
- AsyncClient.getPosts(channel.id);
+ function userVisitedFakeChannel(chan, success, fail) {
+ const otherUserId = Utils.getUserIdFromChannelName(chan);
+ Client.createDirectChannel(
+ chan,
+ otherUserId,
+ (data) => {
+ success(data);
+ },
+ () => {
+ fail();
+ }
+ );
+ }
+ function switchToChannel(chan) {
+ AsyncClient.getChannels(true);
+ AsyncClient.getChannelExtraInfo(chan.id);
+ AsyncClient.updateLastViewedAt(chan.id);
+ AsyncClient.getPosts(chan.id);
+ Client.trackPage();
+
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.CLICK_CHANNEL,
+ name: chan.name,
+ id: chan.id,
+ prev: ChannelStore.getCurrentId()
+ });
+ }
- AppDispatcher.handleViewAction({
- type: ActionTypes.CLICK_CHANNEL,
- name: channel.name,
- id: channel.id,
- prev: ChannelStore.getCurrentId()
- });
+ if (channel.fake) {
+ userVisitedFakeChannel(
+ channel,
+ (data) => {
+ switchToChannel(data);
+ },
+ () => {
+ browserHistory.push('/' + this.state.currentTeam.name);
+ }
+ );
+ } else {
+ switchToChannel(channel);
+ }
}
export function emitPostFocusEvent(postId) {
+ AsyncClient.getChannels(true);
Client.getPostById(
postId,
(data) => {
@@ -39,6 +72,8 @@ export function emitPostFocusEvent(postId) {
post_list: data
});
+ AsyncClient.getChannelExtraInfo(data.channel_id);
+
AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS);
AsyncClient.getPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS);
}
@@ -264,6 +299,15 @@ export function newLocalizationSelected(locale) {
}
}
+export function loadBrowserLocale() {
+ let locale = (navigator.languages && navigator.languages.length > 0 ? navigator.languages[0] :
+ (navigator.language || navigator.userLanguage)).split('-')[0];
+ if (!I18n.getLanguages()[locale]) {
+ locale = 'en';
+ }
+ return newLocalizationSelected(locale);
+}
+
export function viewLoggedIn() {
AsyncClient.getChannels();
AsyncClient.getChannelExtraInfo();
@@ -291,3 +335,4 @@ export function emitRemoteUserTypingEvent(channelId, userId, postParentId) {
postParentId
});
}
+
diff --git a/webapp/action_creators/websocket_actions.jsx b/webapp/action_creators/websocket_actions.jsx
index 611d53bf7..a66d79d18 100644
--- a/webapp/action_creators/websocket_actions.jsx
+++ b/webapp/action_creators/websocket_actions.jsx
@@ -7,6 +7,7 @@ import PostStore from 'stores/post_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import BrowserStore from 'stores/browser_store.jsx';
import ErrorStore from 'stores/error_store.jsx';
+import NotificationStore from 'stores/notification_store.jsx'; //eslint-disable-line no-unused-vars
import * as Utils from 'utils/utils.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
@@ -21,6 +22,7 @@ const WEBSOCKET_RETRY_TIME = 3000;
var conn = null;
var connectFailCount = 0;
var pastFirstInit = false;
+var manuallyClosed = false;
export function initialize() {
if (window.WebSocket && !conn) {
@@ -35,6 +37,8 @@ export function initialize() {
console.log('websocket connecting to ' + connUrl); //eslint-disable-line no-console
}
+ manuallyClosed = false;
+
conn = new WebSocket(connUrl);
conn.onopen = () => {
@@ -63,18 +67,20 @@ export function initialize() {
connectFailCount = connectFailCount + 1;
if (connectFailCount > MAX_WEBSOCKET_FAILS) {
- ErrorStore.storeLastError(Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'));
+ ErrorStore.storeLastError({message: Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.')});
}
ErrorStore.setConnectionErrorCount(connectFailCount);
ErrorStore.emitChange();
- setTimeout(
- () => {
- initialize();
- },
- WEBSOCKET_RETRY_TIME
- );
+ if (!manuallyClosed) {
+ setTimeout(
+ () => {
+ initialize();
+ },
+ WEBSOCKET_RETRY_TIME
+ );
+ }
};
conn.onerror = (evt) => {
@@ -147,6 +153,7 @@ export function sendMessage(msg) {
}
export function close() {
+ manuallyClosed = true;
if (conn && conn.readyState === WebSocket.OPEN) {
conn.close();
}
diff --git a/webapp/components/about_build_modal.jsx b/webapp/components/about_build_modal.jsx
index e73d842d0..4fd946401 100644
--- a/webapp/components/about_build_modal.jsx
+++ b/webapp/components/about_build_modal.jsx
@@ -6,6 +6,7 @@ import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import React from 'react';
+import Constants from 'utils/constants.jsx';
export default class AboutBuildModal extends React.Component {
constructor(props) {
@@ -20,6 +21,7 @@ export default class AboutBuildModal extends React.Component {
render() {
const config = global.window.mm_config;
const license = global.window.mm_license;
+ const mattermostLogo = Constants.MATTERMOST_ICON_SVG;
let title = (
<FormattedMessage
@@ -28,6 +30,28 @@ export default class AboutBuildModal extends React.Component {
/>
);
+ let subTitle = (
+ <FormattedMessage
+ id='about.teamEditionSt'
+ defaultMessage='All your team communication in one place, instantly searchable and accessible anywhere.'
+ />
+ );
+
+ let learnMore = (
+ <div>
+ <FormattedMessage
+ id='about.teamEditionLearn'
+ defaultMessage='Join the Mattermost community at '
+ />
+ <a
+ target='_blank'
+ href='http://www.mattermost.org/'
+ >
+ {'mattermost.org'}
+ </a>
+ </div>
+ );
+
let licensee;
if (config.BuildEnterpriseReady === 'true') {
title = (
@@ -36,6 +60,29 @@ export default class AboutBuildModal extends React.Component {
defaultMessage='Enterprise Edition'
/>
);
+
+ subTitle = (
+ <FormattedMessage
+ id='about.enterpriseEditionSt'
+ defaultMessage='Modern enterprise communication from behind your firewall.'
+ />
+ );
+
+ learnMore = (
+ <div>
+ <FormattedMessage
+ id='about.enterpriseEditionLearn'
+ defaultMessage='Learn more about Enterprise Edition at '
+ />
+ <a
+ target='_blank'
+ href='http://about.mattermost.com/'
+ >
+ {'about.mattermost.com'}
+ </a>
+ </div>
+ );
+
if (license.IsLicensed === 'true') {
title = (
<FormattedMessage
@@ -44,14 +91,12 @@ export default class AboutBuildModal extends React.Component {
/>
);
licensee = (
- <div className='row form-group'>
- <div className='col-sm-3 info__label'>
- <FormattedMessage
- id='about.licensed'
- defaultMessage='Licensed by:'
- />
- </div>
- <div className='col-sm-9'>{license.Company}</div>
+ <div className='form-group'>
+ <FormattedMessage
+ id='about.licensed'
+ defaultMessage='Licensed by:'
+ />
+ &nbsp;{license.Company}
</div>
);
}
@@ -59,6 +104,7 @@ export default class AboutBuildModal extends React.Component {
return (
<Modal
+ dialogClassName='about-modal'
show={this.props.show}
onHide={this.doHide}
>
@@ -71,57 +117,54 @@ export default class AboutBuildModal extends React.Component {
</Modal.Title>
</Modal.Header>
<Modal.Body>
- <h4>{'Mattermost'} {title}</h4>
- {licensee}
- <div className='row form-group'>
- <div className='col-sm-3 info__label'>
- <FormattedMessage
- id='about.version'
- defaultMessage='Version:'
+ <div className='about-modal__content'>
+ <div className='about-modal__logo'>
+ <span
+ className='icon'
+ dangerouslySetInnerHTML={{__html: mattermostLogo}}
/>
</div>
- <div className='col-sm-9'>{config.Version}</div>
- </div>
- <div className='row form-group'>
- <div className='col-sm-3 info__label'>
- <FormattedMessage
- id='about.number'
- defaultMessage='Build Number:'
- />
+ <div>
+ <h3 className='about-modal__title'>{'Mattermost'} {title}</h3>
+ <p className='about-modal__subtitle padding-bottom'>{subTitle}</p>
+ <div className='form-group less'>
+ <div>
+ <FormattedMessage
+ id='about.version'
+ defaultMessage='Version:'
+ />
+ &nbsp;{config.Version}&nbsp;({config.BuildNumber})
+ </div>
+ </div>
+ {licensee}
</div>
- <div className='col-sm-9'>{config.BuildNumber}</div>
</div>
- <div className='row form-group'>
- <div className='col-sm-3 info__label'>
+ <div className='about-modal__footer'>
+ {learnMore}
+ <div className='form-group about-modal__copyright'>
<FormattedMessage
- id='about.date'
- defaultMessage='Build Date:'
+ id='about.copyright'
+ defaultMessage='Copyright 2016 Mattermost, Inc. All rights reserved'
/>
</div>
- <div className='col-sm-9'>{config.BuildDate}</div>
</div>
- <div className='row form-group'>
- <div className='col-sm-3 info__label'>
+ <div className='about-modal__hash form-group padding-top x2'>
+ <p>
<FormattedMessage
id='about.hash'
defaultMessage='Build Hash:'
/>
- </div>
- <div className='col-sm-9'>{config.BuildHash}</div>
+ &nbsp;{config.BuildHash}
+ </p>
+ <p>
+ <FormattedMessage
+ id='about.date'
+ defaultMessage='Build Date:'
+ />
+ &nbsp;{config.BuildDate}
+ </p>
</div>
</Modal.Body>
- <Modal.Footer>
- <button
- type='button'
- className='btn btn-default'
- onClick={this.doHide}
- >
- <FormattedMessage
- id='about.close'
- defaultMessage='Close'
- />
- </button>
- </Modal.Footer>
</Modal>
);
}
diff --git a/webapp/components/admin_console/admin_navbar_dropdown.jsx b/webapp/components/admin_console/admin_navbar_dropdown.jsx
index 527f97959..729d4b14d 100644
--- a/webapp/components/admin_console/admin_navbar_dropdown.jsx
+++ b/webapp/components/admin_console/admin_navbar_dropdown.jsx
@@ -64,7 +64,7 @@ export default class AdminNavbarDropdown extends React.Component {
>
<li>
<Link
- to={Utils.getWindowLocationOrigin() + '/' + this.state.currentTeam.name + '/channels/town-square'}
+ to={'/' + this.state.currentTeam.name + '/channels/town-square'}
>
<FormattedMessage
id='admin.nav.switch'
diff --git a/webapp/components/admin_console/compliance_settings.jsx b/webapp/components/admin_console/compliance_settings.jsx
index fb2ae26f9..206bb0faa 100644
--- a/webapp/components/admin_console/compliance_settings.jsx
+++ b/webapp/components/admin_console/compliance_settings.jsx
@@ -223,7 +223,7 @@ export default class ComplianceSettings extends React.Component {
</label>
<p className='help-text'>
<FormattedMessage
- id='admin.compliance.enableDesc'
+ id='admin.compliance.enableDailyDesc'
defaultMessage='When true, Mattermost will generate a daily compliance report.'
/>
</p>
diff --git a/webapp/components/admin_console/service_settings.jsx b/webapp/components/admin_console/service_settings.jsx
index 881d22d76..2c3f4081c 100644
--- a/webapp/components/admin_console/service_settings.jsx
+++ b/webapp/components/admin_console/service_settings.jsx
@@ -87,6 +87,10 @@ class ServiceSettings extends React.Component {
config.ServiceSettings.EnableCommands = ReactDOM.findDOMNode(this.refs.EnableCommands).checked;
config.ServiceSettings.EnableOnlyAdminIntegrations = ReactDOM.findDOMNode(this.refs.EnableOnlyAdminIntegrations).checked;
+ if (this.refs.EnableMultifactorAuthentication) {
+ config.ServiceSettings.EnableMultifactorAuthentication = ReactDOM.findDOMNode(this.refs.EnableMultifactorAuthentication).checked;
+ }
+
//config.ServiceSettings.EnableOAuthServiceProvider = ReactDOM.findDOMNode(this.refs.EnableOAuthServiceProvider).checked;
var MaximumLoginAttempts = DefaultMaximumLoginAttempts;
@@ -173,6 +177,58 @@ class ServiceSettings extends React.Component {
saveClass = 'btn btn-primary';
}
+ let mfaSetting;
+ if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.MFA === 'true') {
+ mfaSetting = (
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='EnableMultifactorAuthentication'
+ >
+ <FormattedMessage
+ id='admin.service.mfaTitle'
+ defaultMessage='Enable Multi-factor Authentication:'
+ />
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableMultifactorAuthentication'
+ value='true'
+ ref='EnableMultifactorAuthentication'
+ defaultChecked={this.props.config.ServiceSettings.EnableMultifactorAuthentication}
+ onChange={this.handleChange}
+ />
+ <FormattedMessage
+ id='admin.service.true'
+ defaultMessage='true'
+ />
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableMultifactorAuthentication'
+ value='false'
+ defaultChecked={!this.props.config.ServiceSettings.EnableMultifactorAuthentication}
+ onChange={this.handleChange}
+ />
+ <FormattedMessage
+ id='admin.service.false'
+ defaultMessage='false'
+ />
+ </label>
+ <p className='help-text'>
+ <FormattedMessage
+ id='admin.service.mfaDesc'
+ defaultMessage='When true, users will be given the option to add multi-factor authentication to their account. They will need a smartphone and an authenticator app such as Google Authenticator.'
+ />
+ </p>
+ </div>
+ </div>
+ );
+ }
+
return (
<div className='wrapper--fixed'>
@@ -773,6 +829,8 @@ class ServiceSettings extends React.Component {
</div>
</div>
+ {mfaSetting}
+
<div className='form-group'>
<label
className='control-label col-sm-4'
diff --git a/webapp/components/admin_console/user_item.jsx b/webapp/components/admin_console/user_item.jsx
index 91f567d4d..c00050584 100644
--- a/webapp/components/admin_console/user_item.jsx
+++ b/webapp/components/admin_console/user_item.jsx
@@ -333,7 +333,7 @@ export default class UserItem extends React.Component {
<div>
<FormattedMessage
id='admin.user_item.confirmDemoteDescription'
- defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command."
+ defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command."
/>
<br/>
<br/>
diff --git a/webapp/components/analytics/team_analytics.jsx b/webapp/components/analytics/team_analytics.jsx
index efc965f24..9b4eb1f94 100644
--- a/webapp/components/analytics/team_analytics.jsx
+++ b/webapp/components/analytics/team_analytics.jsx
@@ -154,7 +154,7 @@ class TeamAnalytics extends React.Component {
<TableChart
title={
<FormattedMessage
- id='analytics.team.activeUsers'
+ id='analytics.team.recentUsers'
defaultMessage='Recent Active Users'
/>
}
diff --git a/webapp/components/backstage/add_incoming_webhook.jsx b/webapp/components/backstage/add_incoming_webhook.jsx
new file mode 100644
index 000000000..83027c6b3
--- /dev/null
+++ b/webapp/components/backstage/add_incoming_webhook.jsx
@@ -0,0 +1,198 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import * as AsyncClient from 'utils/async_client.jsx';
+import {browserHistory} from 'react-router';
+
+import ChannelSelect from 'components/channel_select.jsx';
+import {FormattedMessage} from 'react-intl';
+import FormError from 'components/form_error.jsx';
+import {Link} from 'react-router';
+import SpinnerButton from 'components/spinner_button.jsx';
+
+export default class AddIncomingWebhook extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+
+ this.updateName = this.updateName.bind(this);
+ this.updateDescription = this.updateDescription.bind(this);
+ this.updateChannelId = this.updateChannelId.bind(this);
+
+ this.state = {
+ name: '',
+ description: '',
+ channelId: '',
+ saving: false,
+ serverError: '',
+ clientError: null
+ };
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ if (this.state.saving) {
+ return;
+ }
+
+ this.setState({
+ saving: true,
+ serverError: '',
+ clientError: ''
+ });
+
+ if (!this.state.channelId) {
+ this.setState({
+ saving: false,
+ clientError: (
+ <FormattedMessage
+ id='add_incoming_webhook.channelRequired'
+ defaultMessage='A valid channel is required'
+ />
+ )
+ });
+
+ return;
+ }
+
+ const hook = {
+ channel_id: this.state.channelId
+ };
+
+ AsyncClient.addIncomingHook(
+ hook,
+ () => {
+ browserHistory.push('/settings/integrations/installed');
+ },
+ (err) => {
+ this.setState({
+ serverError: err.message
+ });
+ }
+ );
+ }
+
+ updateName(e) {
+ this.setState({
+ name: e.target.value
+ });
+ }
+
+ updateDescription(e) {
+ this.setState({
+ description: e.target.value
+ });
+ }
+
+ updateChannelId(e) {
+ this.setState({
+ channelId: e.target.value
+ });
+ }
+
+ render() {
+ return (
+ <div className='backstage-content row'>
+ <div className='add-incoming-webhook'>
+ <div className='backstage-header'>
+ <h1>
+ <FormattedMessage
+ id='add_incoming_webhook.header'
+ defaultMessage='Add Incoming Webhook'
+ />
+ </h1>
+ </div>
+ </div>
+ <div className='backstage-form'>
+ <form className='form-horizontal'>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='name'
+ >
+ <FormattedMessage
+ id='add_incoming_webhook.name'
+ defaultMessage='Name'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <input
+ id='name'
+ type='text'
+ className='form-control'
+ value={this.state.name}
+ onChange={this.updateName}
+ />
+ </div>
+ </div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='description'
+ >
+ <FormattedMessage
+ id='add_incoming_webhook.description'
+ defaultMessage='Description'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <input
+ id='description'
+ type='text'
+ className='form-control'
+ value={this.state.description}
+ onChange={this.updateDescription}
+ />
+ </div>
+ </div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='channelId'
+ >
+ <FormattedMessage
+ id='add_incoming_webhook.channel'
+ defaultMessage='Channel'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <ChannelSelect
+ id='channelId'
+ value={this.state.channelId}
+ onChange={this.updateChannelId}
+ />
+ </div>
+ </div>
+ <div className='backstage-form__footer'>
+ <FormError errors={[this.state.serverError, this.state.clientError]}/>
+ <Link
+ className='btn btn-sm'
+ to={'/settings/integrations/add'}
+ >
+ <FormattedMessage
+ id='add_incoming_webhook.cancel'
+ defaultMessage='Cancel'
+ />
+ </Link>
+ <SpinnerButton
+ className='btn btn-primary'
+ type='submit'
+ spinning={this.state.saving}
+ onClick={this.handleSubmit}
+ >
+ <FormattedMessage
+ id='add_incoming_webhook.save'
+ defaultMessage='Save'
+ />
+ </SpinnerButton>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/backstage/add_integration.jsx b/webapp/components/backstage/add_integration.jsx
new file mode 100644
index 000000000..5f4a69bfe
--- /dev/null
+++ b/webapp/components/backstage/add_integration.jsx
@@ -0,0 +1,76 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import {FormattedMessage} from 'react-intl';
+import AddIntegrationOption from './add_integration_option.jsx';
+
+import WebhookIcon from 'images/webhook_icon.jpg';
+
+export default class AddIntegration extends React.Component {
+ render() {
+ const options = [];
+
+ if (window.mm_config.EnableIncomingWebhooks === 'true') {
+ options.push(
+ <AddIntegrationOption
+ key='incomingWebhook'
+ image={WebhookIcon}
+ title={
+ <FormattedMessage
+ id='add_integration.incomingWebhook.title'
+ defaultMessage='Incoming Webhook'
+ />
+ }
+ description={
+ <FormattedMessage
+ id='add_integration.incomingWebhook.description'
+ defaultMessage='Create webhook URLs for use in external integrations.'
+ />
+ }
+ link={'/settings/integrations/add/incoming_webhook'}
+ />
+ );
+ }
+
+ if (window.mm_config.EnableOutgoingWebhooks === 'true') {
+ options.push(
+ <AddIntegrationOption
+ key='outgoingWebhook'
+ image={WebhookIcon}
+ title={
+ <FormattedMessage
+ id='add_integration.outgoingWebhook.title'
+ defaultMessage='Outgoing Webhook'
+ />
+ }
+ description={
+ <FormattedMessage
+ id='add_integration.outgoingWebhook.description'
+ defaultMessage='Create webhooks to send new message events to an external integration.'
+ />
+ }
+ link={'/settings/integrations/add/outgoing_webhook'}
+ />
+ );
+ }
+
+ return (
+ <div className='backstage-content row'>
+ <div className='backstage-header'>
+ <h1>
+ <FormattedMessage
+ id='add_integration.header'
+ defaultMessage='Add Integration'
+ />
+ </h1>
+ </div>
+ <div>
+ {options}
+ </div>
+ </div>
+ );
+ }
+}
+
diff --git a/webapp/components/backstage/add_integration_option.jsx b/webapp/components/backstage/add_integration_option.jsx
new file mode 100644
index 000000000..b17ebb185
--- /dev/null
+++ b/webapp/components/backstage/add_integration_option.jsx
@@ -0,0 +1,39 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import {Link} from 'react-router';
+
+export default class AddIntegrationOption extends React.Component {
+ static get propTypes() {
+ return {
+ image: React.PropTypes.string.isRequired,
+ title: React.PropTypes.node.isRequired,
+ description: React.PropTypes.node.isRequired,
+ link: React.PropTypes.string.isRequired
+ };
+ }
+
+ render() {
+ const {image, title, description, link} = this.props;
+
+ return (
+ <Link
+ to={link}
+ className='add-integration'
+ >
+ <img
+ className='add-integration__image'
+ src={image}
+ />
+ <div className='add-integration__title'>
+ {title}
+ </div>
+ <div className='add-integration__description'>
+ {description}
+ </div>
+ </Link>
+ );
+ }
+}
diff --git a/webapp/components/backstage/add_outgoing_webhook.jsx b/webapp/components/backstage/add_outgoing_webhook.jsx
new file mode 100644
index 000000000..5d98138df
--- /dev/null
+++ b/webapp/components/backstage/add_outgoing_webhook.jsx
@@ -0,0 +1,270 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import * as AsyncClient from 'utils/async_client.jsx';
+import {browserHistory} from 'react-router';
+
+import ChannelSelect from 'components/channel_select.jsx';
+import {FormattedMessage} from 'react-intl';
+import FormError from 'components/form_error.jsx';
+import {Link} from 'react-router';
+import SpinnerButton from 'components/spinner_button.jsx';
+
+export default class AddOutgoingWebhook extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+
+ this.updateName = this.updateName.bind(this);
+ this.updateDescription = this.updateDescription.bind(this);
+ this.updateChannelId = this.updateChannelId.bind(this);
+ this.updateTriggerWords = this.updateTriggerWords.bind(this);
+ this.updateCallbackUrls = this.updateCallbackUrls.bind(this);
+
+ this.state = {
+ name: '',
+ description: '',
+ channelId: '',
+ triggerWords: '',
+ callbackUrls: '',
+ saving: false,
+ serverError: '',
+ clientError: null
+ };
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ if (this.state.saving) {
+ return;
+ }
+
+ this.setState({
+ saving: true,
+ serverError: '',
+ clientError: ''
+ });
+
+ if (!this.state.channelId && !this.state.triggerWords) {
+ this.setState({
+ saving: false,
+ clientError: (
+ <FormattedMessage
+ id='add_outgoing_webhook.triggerWordsOrChannelRequired'
+ defaultMessage='A valid channel or a list of trigger words is required'
+ />
+ )
+ });
+
+ return;
+ }
+
+ if (!this.state.callbackUrls) {
+ this.setState({
+ saving: false,
+ clientError: (
+ <FormattedMessage
+ id='add_outgoing_webhook.callbackUrlsRequired'
+ defaultMessage='One or more callback URLs are required'
+ />
+ )
+ });
+
+ return;
+ }
+
+ const hook = {
+ channel_id: this.state.channelId,
+ trigger_words: this.state.triggerWords.split('\n').map((word) => word.trim()),
+ callback_urls: this.state.callbackUrls.split('\n').map((url) => url.trim())
+ };
+
+ AsyncClient.addOutgoingHook(
+ hook,
+ () => {
+ browserHistory.push('/settings/integrations/installed');
+ },
+ (err) => {
+ this.setState({
+ serverError: err.message
+ });
+ }
+ );
+ }
+
+ updateName(e) {
+ this.setState({
+ name: e.target.value
+ });
+ }
+
+ updateDescription(e) {
+ this.setState({
+ description: e.target.value
+ });
+ }
+
+ updateChannelId(e) {
+ this.setState({
+ channelId: e.target.value
+ });
+ }
+
+ updateTriggerWords(e) {
+ this.setState({
+ triggerWords: e.target.value
+ });
+ }
+
+ updateCallbackUrls(e) {
+ this.setState({
+ callbackUrls: e.target.value
+ });
+ }
+
+ render() {
+ return (
+ <div className='backstage-content row'>
+ <div className='add-outgoing-webhook'>
+ <div className='backstage-header'>
+ <h1>
+ <FormattedMessage
+ id='add_outgoing_webhook.header'
+ defaultMessage='Add Outgoing Webhook'
+ />
+ </h1>
+ </div>
+ </div>
+ <div className='backstage-form'>
+ <form className='form-horizontal'>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='name'
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.name'
+ defaultMessage='Name'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <input
+ id='name'
+ type='text'
+ className='form-control'
+ value={this.state.name}
+ onChange={this.updateName}
+ />
+ </div>
+ </div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='description'
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.description'
+ defaultMessage='Description'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <input
+ id='description'
+ type='text'
+ className='form-control'
+ value={this.state.description}
+ onChange={this.updateDescription}
+ />
+ </div>
+ </div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='channelId'
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.channel'
+ defaultMessage='Channel'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <ChannelSelect
+ id='channelId'
+ value={this.state.channelId}
+ onChange={this.updateChannelId}
+ />
+ </div>
+ </div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='triggerWords'
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.triggerWords'
+ defaultMessage='Trigger Words (One Per Line)'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <textarea
+ id='triggerWords'
+ rows='3'
+ className='form-control'
+ value={this.state.triggerWords}
+ onChange={this.updateTriggerWords}
+ />
+ </div>
+ </div>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-3'
+ htmlFor='callbackUrls'
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.callbackUrls'
+ defaultMessage='Callback URLs (One Per Line)'
+ />
+ </label>
+ <div className='col-md-5 col-sm-9'>
+ <textarea
+ id='callbackUrls'
+ rows='3'
+ className='form-control'
+ value={this.state.callbackUrls}
+ onChange={this.updateCallbackUrls}
+ />
+ </div>
+ </div>
+ <div className='backstage-form__footer'>
+ <FormError errors={[this.state.serverError, this.state.clientError]}/>
+ <Link
+ className='btn btn-sm'
+ to={'/settings/integrations/add'}
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.cancel'
+ defaultMessage='Cancel'
+ />
+ </Link>
+ <SpinnerButton
+ className='btn btn-primary'
+ type='submit'
+ spinning={this.state.saving}
+ onClick={this.handleSubmit}
+ >
+ <FormattedMessage
+ id='add_outgoing_webhook.save'
+ defaultMessage='Save'
+ />
+ </SpinnerButton>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/backstage/backstage_category.jsx b/webapp/components/backstage/backstage_category.jsx
new file mode 100644
index 000000000..913c7562c
--- /dev/null
+++ b/webapp/components/backstage/backstage_category.jsx
@@ -0,0 +1,68 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import {Link} from 'react-router';
+
+export default class BackstageCategory extends React.Component {
+ static get propTypes() {
+ return {
+ name: React.PropTypes.string.isRequired,
+ title: React.PropTypes.node.isRequired,
+ icon: React.PropTypes.string.isRequired,
+ parentLink: React.PropTypes.string,
+ children: React.PropTypes.arrayOf(React.PropTypes.element)
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ parentLink: '',
+ children: []
+ };
+ }
+
+ static get contextTypes() {
+ return {
+ router: React.PropTypes.object.isRequired
+ };
+ }
+
+ render() {
+ const {name, title, icon, parentLink, children} = this.props;
+
+ const link = parentLink + '/' + name;
+
+ let clonedChildren = null;
+ if (children.length > 0 && this.context.router.isActive(link)) {
+ clonedChildren = (
+ <ul className='sections'>
+ {
+ React.Children.map(children, (child) => {
+ return React.cloneElement(child, {
+ parentLink: link
+ });
+ })
+ }
+ </ul>
+ );
+ }
+
+ return (
+ <li className='backstage-sidebar__category'>
+ <Link
+ to={link}
+ className='category-title'
+ activeClassName='category-title--active'
+ >
+ <i className={'fa ' + icon}/>
+ <span className='category-title__text'>
+ {title}
+ </span>
+ </Link>
+ {clonedChildren}
+ </li>
+ );
+ }
+}
diff --git a/webapp/components/backstage/backstage_navbar.jsx b/webapp/components/backstage/backstage_navbar.jsx
new file mode 100644
index 000000000..d1dac6043
--- /dev/null
+++ b/webapp/components/backstage/backstage_navbar.jsx
@@ -0,0 +1,61 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import TeamStore from 'stores/team_store.jsx';
+
+import {FormattedMessage} from 'react-intl';
+import {Link} from 'react-router';
+
+export default class BackstageNavbar extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleChange = this.handleChange.bind(this);
+
+ this.state = {
+ team: TeamStore.getCurrent()
+ };
+ }
+
+ componentDidMount() {
+ TeamStore.addChangeListener(this.handleChange);
+ }
+
+ componentWillUnmount() {
+ TeamStore.removeChangeListener(this.handleChange);
+ }
+
+ handleChange() {
+ this.setState({
+ team: TeamStore.getCurrent()
+ });
+ }
+
+ render() {
+ if (!this.state.team) {
+ return null;
+ }
+
+ return (
+ <div className='backstage-navbar row'>
+ <Link
+ className='backstage-navbar__back'
+ to={`/${this.state.team.display_name}/channels/town-square`}
+ >
+ <i className='fa fa-angle-left'/>
+ <span>
+ <FormattedMessage
+ id='backstage_navbar.backToMattermost'
+ defaultMessage='Back to {siteName}'
+ values={{
+ siteName: global.window.mm_config.SiteName
+ }}
+ />
+ </span>
+ </Link>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/backstage/backstage_section.jsx b/webapp/components/backstage/backstage_section.jsx
new file mode 100644
index 000000000..d6ce2b258
--- /dev/null
+++ b/webapp/components/backstage/backstage_section.jsx
@@ -0,0 +1,80 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import {Link} from 'react-router';
+
+export default class BackstageSection extends React.Component {
+ static get propTypes() {
+ return {
+ name: React.PropTypes.string.isRequired,
+ title: React.PropTypes.node.isRequired,
+ parentLink: React.PropTypes.string,
+ subsection: React.PropTypes.bool,
+ children: React.PropTypes.arrayOf(React.PropTypes.element)
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ parentLink: '',
+ subsection: false,
+ children: []
+ };
+ }
+
+ static get contextTypes() {
+ return {
+ router: React.PropTypes.object.isRequired
+ };
+ }
+
+ getLink() {
+ return this.props.parentLink + '/' + this.props.name;
+ }
+
+ render() {
+ const {title, subsection, children} = this.props;
+
+ const link = this.getLink();
+
+ let clonedChildren = null;
+ if (children.length > 0) {
+ clonedChildren = (
+ <ul className='subsections'>
+ {
+ React.Children.map(children, (child) => {
+ return React.cloneElement(child, {
+ parentLink: link,
+ subsection: true
+ });
+ })
+ }
+ </ul>
+ );
+ }
+
+ let className = 'section';
+ if (subsection) {
+ className = 'subsection';
+ }
+
+ return (
+ <li className={className}>
+ <Link
+ className={`${className}-title`}
+ activeClassName={`${className}-title--active`}
+ onlyActiveOnIndex={true}
+ onClick={this.handleClick}
+ to={link}
+ >
+ <span className={`${className}-title__text`}>
+ {title}
+ </span>
+ </Link>
+ {clonedChildren}
+ </li>
+ );
+ }
+}
diff --git a/webapp/components/backstage/backstage_sidebar.jsx b/webapp/components/backstage/backstage_sidebar.jsx
new file mode 100644
index 000000000..13c4f8b50
--- /dev/null
+++ b/webapp/components/backstage/backstage_sidebar.jsx
@@ -0,0 +1,68 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import BackstageCategory from './backstage_category.jsx';
+import BackstageSection from './backstage_section.jsx';
+import {FormattedMessage} from 'react-intl';
+
+export default class BackstageSidebar extends React.Component {
+ render() {
+ return (
+ <div className='backstage-sidebar'>
+ <ul>
+ <BackstageCategory
+ name='integrations'
+ parentLink={'/settings'}
+ icon='fa-link'
+ title={
+ <FormattedMessage
+ id='backstage_sidebar.integrations'
+ defaultMessage='Integrations'
+ />
+ }
+ >
+ <BackstageSection
+ name='installed'
+ title={(
+ <FormattedMessage
+ id='backstage_sidebar.integrations.installed'
+ defaultMessage='Installed Integrations'
+ />
+ )}
+ />
+ <BackstageSection
+ name='add'
+ title={(
+ <FormattedMessage
+ id='backstage_sidebar.integrations.add'
+ defaultMessage='Add Integration'
+ />
+ )}
+ >
+ <BackstageSection
+ name='incoming_webhook'
+ title={(
+ <FormattedMessage
+ id='backstage_sidebar.integrations.add.incomingWebhook'
+ defaultMessage='Incoming Webhook'
+ />
+ )}
+ />
+ <BackstageSection
+ name='outgoing_webhook'
+ title={(
+ <FormattedMessage
+ id='backstage_sidebar.integrations.add.outgoingWebhook'
+ defaultMessage='Outgoing Webhook'
+ />
+ )}
+ />
+ </BackstageSection>
+ </BackstageCategory>
+ </ul>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/backstage/installed_incoming_webhook.jsx b/webapp/components/backstage/installed_incoming_webhook.jsx
new file mode 100644
index 000000000..f65cf6327
--- /dev/null
+++ b/webapp/components/backstage/installed_incoming_webhook.jsx
@@ -0,0 +1,71 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import ChannelStore from 'stores/channel_store.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+import {FormattedMessage} from 'react-intl';
+
+export default class InstalledIncomingWebhook extends React.Component {
+ static get propTypes() {
+ return {
+ incomingWebhook: React.PropTypes.object.isRequired,
+ onDeleteClick: React.PropTypes.func.isRequired
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.handleDeleteClick = this.handleDeleteClick.bind(this);
+ }
+
+ handleDeleteClick(e) {
+ e.preventDefault();
+
+ this.props.onDeleteClick(this.props.incomingWebhook);
+ }
+
+ render() {
+ const incomingWebhook = this.props.incomingWebhook;
+
+ const channel = ChannelStore.get(incomingWebhook.channel_id);
+ const channelName = channel ? channel.display_name : 'cannot find channel';
+
+ return (
+ <div className='backstage-list__item'>
+ <div className='item-details'>
+ <div className='item-details__row'>
+ <span className='item-details__name'>
+ {channelName}
+ </span>
+ <span className='item-details__type'>
+ <FormattedMessage
+ id='installed_integrations.incomingWebhookType'
+ defaultMessage='(Incoming Webhook)'
+ />
+ </span>
+ </div>
+ <div className='item-details__row'>
+ <span className='item-details__description'>
+ {Utils.getWindowLocationOrigin() + '/hooks/' + incomingWebhook.id}
+ </span>
+ </div>
+ </div>
+ <div className='item-actions'>
+ <a
+ href='#'
+ onClick={this.handleDeleteClick}
+ >
+ <FormattedMessage
+ id='installed_integrations.delete'
+ defaultMessage='Delete'
+ />
+ </a>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/backstage/installed_integrations.jsx b/webapp/components/backstage/installed_integrations.jsx
new file mode 100644
index 000000000..fe84ae81a
--- /dev/null
+++ b/webapp/components/backstage/installed_integrations.jsx
@@ -0,0 +1,293 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import * as AsyncClient from 'utils/async_client.jsx';
+import ChannelStore from 'stores/channel_store.jsx';
+import IntegrationStore from 'stores/integration_store.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+import {FormattedMessage} from 'react-intl';
+import InstalledIncomingWebhook from './installed_incoming_webhook.jsx';
+import InstalledOutgoingWebhook from './installed_outgoing_webhook.jsx';
+import {Link} from 'react-router';
+
+export default class InstalledIntegrations extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleIntegrationChange = this.handleIntegrationChange.bind(this);
+ this.updateFilter = this.updateFilter.bind(this);
+ this.updateTypeFilter = this.updateTypeFilter.bind(this);
+
+ this.deleteIncomingWebhook = this.deleteIncomingWebhook.bind(this);
+ this.regenOutgoingWebhookToken = this.regenOutgoingWebhookToken.bind(this);
+ this.deleteOutgoingWebhook = this.deleteOutgoingWebhook.bind(this);
+
+ this.state = {
+ incomingWebhooks: [],
+ outgoingWebhooks: [],
+ typeFilter: '',
+ filter: ''
+ };
+ }
+
+ componentWillMount() {
+ IntegrationStore.addChangeListener(this.handleIntegrationChange);
+
+ if (window.mm_config.EnableIncomingWebhooks === 'true') {
+ if (IntegrationStore.hasReceivedIncomingWebhooks()) {
+ this.setState({
+ incomingWebhooks: IntegrationStore.getIncomingWebhooks()
+ });
+ } else {
+ AsyncClient.listIncomingHooks();
+ }
+ }
+
+ if (window.mm_config.EnableOutgoingWebhooks === 'true') {
+ if (IntegrationStore.hasReceivedOutgoingWebhooks()) {
+ this.setState({
+ outgoingWebhooks: IntegrationStore.getOutgoingWebhooks()
+ });
+ } else {
+ AsyncClient.listOutgoingHooks();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ IntegrationStore.removeChangeListener(this.handleIntegrationChange);
+ }
+
+ handleIntegrationChange() {
+ this.setState({
+ incomingWebhooks: IntegrationStore.getIncomingWebhooks(),
+ outgoingWebhooks: IntegrationStore.getOutgoingWebhooks()
+ });
+ }
+
+ updateTypeFilter(e, typeFilter) {
+ e.preventDefault();
+
+ this.setState({
+ typeFilter
+ });
+ }
+
+ updateFilter(e) {
+ this.setState({
+ filter: e.target.value
+ });
+ }
+
+ deleteIncomingWebhook(incomingWebhook) {
+ AsyncClient.deleteIncomingHook(incomingWebhook.id);
+ }
+
+ regenOutgoingWebhookToken(outgoingWebhook) {
+ AsyncClient.regenOutgoingHookToken(outgoingWebhook.id);
+ }
+
+ deleteOutgoingWebhook(outgoingWebhook) {
+ AsyncClient.deleteOutgoingHook(outgoingWebhook.id);
+ }
+
+ renderTypeFilters(incomingWebhooks, outgoingWebhooks) {
+ const fields = [];
+
+ if (incomingWebhooks.length > 0 || outgoingWebhooks.length > 0) {
+ let filterClassName = 'filter-sort';
+ if (this.state.typeFilter === '') {
+ filterClassName += ' filter-sort--active';
+ }
+
+ fields.push(
+ <a
+ key='allFilter'
+ className={filterClassName}
+ href='#'
+ onClick={(e) => this.updateTypeFilter(e, '')}
+ >
+ <FormattedMessage
+ id='installed_integrations.allFilter'
+ defaultMessage='All ({count})'
+ values={{
+ count: incomingWebhooks.length + outgoingWebhooks.length
+ }}
+ />
+ </a>
+ );
+ }
+
+ if (incomingWebhooks.length > 0) {
+ fields.push(
+ <span
+ key='incomingWebhooksDivider'
+ className='divider'
+ >
+ {'|'}
+ </span>
+ );
+
+ let filterClassName = 'filter-sort';
+ if (this.state.typeFilter === 'incomingWebhooks') {
+ filterClassName += ' filter-sort--active';
+ }
+
+ fields.push(
+ <a
+ key='incomingWebhooksFilter'
+ className={filterClassName}
+ href='#'
+ onClick={(e) => this.updateTypeFilter(e, 'incomingWebhooks')}
+ >
+ <FormattedMessage
+ id='installed_integrations.incomingWebhooksFilter'
+ defaultMessage='Incoming Webhooks ({count})'
+ values={{
+ count: incomingWebhooks.length
+ }}
+ />
+ </a>
+ );
+ }
+
+ if (outgoingWebhooks.length > 0) {
+ fields.push(
+ <span
+ key='outgoingWebhooksDivider'
+ className='divider'
+ >
+ {'|'}
+ </span>
+ );
+
+ let filterClassName = 'filter-sort';
+ if (this.state.typeFilter === 'outgoingWebhooks') {
+ filterClassName += ' filter-sort--active';
+ }
+
+ fields.push(
+ <a
+ key='outgoingWebhooksFilter'
+ className={filterClassName}
+ href='#'
+ onClick={(e) => this.updateTypeFilter(e, 'outgoingWebhooks')}
+ >
+ <FormattedMessage
+ id='installed_integrations.outgoingWebhooksFilter'
+ defaultMessage='Outgoing Webhooks ({count})'
+ values={{
+ count: outgoingWebhooks.length
+ }}
+ />
+ </a>
+ );
+ }
+
+ return (
+ <div className='backstage-filters__sort'>
+ {fields}
+ </div>
+ );
+ }
+
+ render() {
+ const incomingWebhooks = this.state.incomingWebhooks;
+ const outgoingWebhooks = this.state.outgoingWebhooks;
+
+ const filter = this.state.filter.toLowerCase();
+
+ const integrations = [];
+ if (!this.state.typeFilter || this.state.typeFilter === 'incomingWebhooks') {
+ for (const incomingWebhook of incomingWebhooks) {
+ if (filter) {
+ const channel = ChannelStore.get(incomingWebhook.channel_id);
+
+ if (!channel || channel.name.toLowerCase().indexOf(filter) === -1) {
+ continue;
+ }
+ }
+
+ integrations.push(
+ <InstalledIncomingWebhook
+ key={incomingWebhook.id}
+ incomingWebhook={incomingWebhook}
+ onDeleteClick={this.deleteIncomingWebhook}
+ />
+ );
+ }
+ }
+
+ if (!this.state.typeFilter || this.state.typeFilter === 'outgoingWebhooks') {
+ for (const outgoingWebhook of outgoingWebhooks) {
+ if (filter) {
+ const channel = ChannelStore.get(outgoingWebhook.channel_id);
+
+ if (!channel || channel.name.toLowerCase().indexOf(filter) === -1) {
+ continue;
+ }
+ }
+
+ integrations.push(
+ <InstalledOutgoingWebhook
+ key={outgoingWebhook.id}
+ outgoingWebhook={outgoingWebhook}
+ onRegenToken={this.regenOutgoingWebhookToken}
+ onDelete={this.deleteOutgoingWebhook}
+ />
+ );
+ }
+ }
+
+ return (
+ <div className='backstage-content row'>
+ <div className='installed-integrations'>
+ <div className='backstage-header'>
+ <h1>
+ <FormattedMessage
+ id='installed_integrations.header'
+ defaultMessage='Installed Integrations'
+ />
+ </h1>
+ <Link
+ className='add-integrations-link'
+ to={'/settings/integrations/add'}
+ >
+ <button
+ type='button'
+ className='btn btn-primary'
+ >
+ <span>
+ <FormattedMessage
+ id='installed_integrations.add'
+ defaultMessage='Add Integration'
+ />
+ </span>
+ </button>
+ </Link>
+ </div>
+ <div className='backstage-filters'>
+ {this.renderTypeFilters(this.state.incomingWebhooks, this.state.outgoingWebhooks)}
+ <div className='backstage-filter__search'>
+ <i className='fa fa-search'></i>
+ <input
+ type='search'
+ className='form-control'
+ placeholder={Utils.localizeMessage('installed_integrations.search', 'Search Integrations')}
+ value={this.state.filter}
+ onChange={this.updateFilter}
+ style={{flexGrow: 0, flexShrink: 0}}
+ />
+ </div>
+ </div>
+ <div className='backstage-list'>
+ {integrations}
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/backstage/installed_outgoing_webhook.jsx b/webapp/components/backstage/installed_outgoing_webhook.jsx
new file mode 100644
index 000000000..fee427260
--- /dev/null
+++ b/webapp/components/backstage/installed_outgoing_webhook.jsx
@@ -0,0 +1,91 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import ChannelStore from 'stores/channel_store.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+import {FormattedMessage} from 'react-intl';
+
+export default class InstalledOutgoingWebhook extends React.Component {
+ static get propTypes() {
+ return {
+ outgoingWebhook: React.PropTypes.object.isRequired,
+ onRegenToken: React.PropTypes.func.isRequired,
+ onDelete: React.PropTypes.func.isRequired
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.handleRegenToken = this.handleRegenToken.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
+ }
+
+ handleRegenToken(e) {
+ e.preventDefault();
+
+ this.props.onRegenToken(this.props.outgoingWebhook);
+ }
+
+ handleDelete(e) {
+ e.preventDefault();
+
+ this.props.onDelete(this.props.outgoingWebhook);
+ }
+
+ render() {
+ const outgoingWebhook = this.props.outgoingWebhook;
+
+ const channel = ChannelStore.get(outgoingWebhook.channel_id);
+ const channelName = channel ? channel.display_name : 'cannot find channel';
+
+ return (
+ <div className='backstage-list__item'>
+ <div className='item-details'>
+ <div className='item-details__row'>
+ <span className='item-details__name'>
+ {channelName}
+ </span>
+ <span className='item-details__type'>
+ <FormattedMessage
+ id='installed_integrations.outgoingWebhookType'
+ defaultMessage='(Outgoing Webhook)'
+ />
+ </span>
+ </div>
+ <div className='item-details__row'>
+ <span className='item-details__description'>
+ {Utils.getWindowLocationOrigin() + '/hooks/' + outgoingWebhook.id}
+ {' - '}
+ {outgoingWebhook.token}
+ </span>
+ </div>
+ </div>
+ <div className='actions'>
+ <a
+ href='#'
+ onClick={this.handleRegenToken}
+ >
+ <FormattedMessage
+ id='installed_integrations.regenToken'
+ defaultMessage='Regen Token'
+ />
+ </a>
+ {' - '}
+ <a
+ href='#'
+ onClick={this.handleDelete}
+ >
+ <FormattedMessage
+ id='installed_integrations.delete'
+ defaultMessage='Delete'
+ />
+ </a>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/center_panel.jsx b/webapp/components/center_panel.jsx
deleted file mode 100644
index 62b12c1d2..000000000
--- a/webapp/components/center_panel.jsx
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import TutorialIntroScreens from './tutorial/tutorial_intro_screens.jsx';
-import CreatePost from './create_post.jsx';
-import PostsViewContainer from './posts_view_container.jsx';
-import PostFocusView from './post_focus_view.jsx';
-import ChannelHeader from './channel_header.jsx';
-import Navbar from './navbar.jsx';
-import FileUploadOverlay from './file_upload_overlay.jsx';
-
-import PreferenceStore from 'stores/preference_store.jsx';
-import ChannelStore from 'stores/channel_store.jsx';
-import UserStore from 'stores/user_store.jsx';
-
-import * as Utils from 'utils/utils.jsx';
-
-import {FormattedMessage} from 'react-intl';
-
-import Constants from 'utils/constants.jsx';
-const TutorialSteps = Constants.TutorialSteps;
-const Preferences = Constants.Preferences;
-
-import React from 'react';
-import {Link} from 'react-router';
-
-export default class CenterPanel extends React.Component {
- constructor(props) {
- super(props);
-
- this.getStateFromStores = this.getStateFromStores.bind(this);
- this.validState = this.validState.bind(this);
- this.onStoresChange = this.onStoresChange.bind(this);
-
- this.state = this.getStateFromStores();
- }
- getStateFromStores() {
- const tutorialStep = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 999);
- return {
- showTutorialScreens: tutorialStep <= TutorialSteps.INTRO_SCREENS,
- showPostFocus: ChannelStore.getPostMode() === ChannelStore.POST_MODE_FOCUS,
- user: UserStore.getCurrentUser(),
- channel: ChannelStore.getCurrent(),
- profiles: JSON.parse(JSON.stringify(UserStore.getProfiles()))
- };
- }
- validState() {
- return this.state.user && this.state.channel && this.state.profiles;
- }
- onStoresChange() {
- this.setState(this.getStateFromStores());
- }
- componentDidMount() {
- PreferenceStore.addChangeListener(this.onStoresChange);
- ChannelStore.addChangeListener(this.onStoresChange);
- UserStore.addChangeListener(this.onStoresChange);
- }
- componentWillUnmount() {
- PreferenceStore.removeChangeListener(this.onStoresChange);
- ChannelStore.removeChangeListener(this.onStoresChange);
- UserStore.removeChangeListener(this.onStoresChange);
- }
- render() {
- if (!this.validState()) {
- return null;
- }
- const channel = this.state.channel;
- var handleClick = null;
- let postsContainer;
- let createPost;
- if (this.state.showTutorialScreens) {
- postsContainer = <TutorialIntroScreens/>;
- createPost = null;
- } else if (this.state.showPostFocus) {
- postsContainer = <PostFocusView profiles={this.state.profiles}/>;
-
- handleClick = function clickHandler(e) {
- e.preventDefault();
- Utils.switchChannel(channel);
- };
-
- createPost = (
- <div
- id='archive-link-home'
- onClick={handleClick}
- >
- <Link to=''>
- <FormattedMessage
- id='center_panel.recent'
- defaultMessage='Click here to jump to recent messages. '
- />
- <i className='fa fa-arrow-down'></i>
- </Link>
- </div>
- );
- } else {
- postsContainer = <PostsViewContainer profiles={this.state.profiles}/>;
- createPost = (
- <div
- className='post-create__container'
- id='post-create'
- >
- <CreatePost/>
- </div>
- );
- }
-
- return (
- <div className='inner-wrap channel__wrap'>
- <div className='row header'>
- <div id='navbar'>
- <Navbar/>
- </div>
- </div>
- <div className='row main'>
- <FileUploadOverlay
- id='file_upload_overlay'
- overlayType='center'
- />
- <div
- id='app-content'
- className='app__content'
- >
- <div
- id='channel-header'
- className='channel-header'
- >
- <ChannelHeader
- user={this.state.user}
- />
- </div>
- {postsContainer}
- {createPost}
- </div>
- </div>
- </div>
- );
- }
-}
-
-CenterPanel.defaultProps = {
-};
-
-CenterPanel.propTypes = {
-};
diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx
index 369fa2dbb..482aabc01 100644
--- a/webapp/components/channel_header.jsx
+++ b/webapp/components/channel_header.jsx
@@ -26,6 +26,7 @@ import * as Utils from 'utils/utils.jsx';
import * as TextFormatting from 'utils/text_formatting.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Client from 'utils/client.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import Constants from 'utils/constants.jsx';
import {FormattedMessage} from 'react-intl';
@@ -53,11 +54,11 @@ export default class ChannelHeader extends React.Component {
this.state = state;
}
getStateFromStores() {
- const extraInfo = ChannelStore.getCurrentExtraInfo();
+ const extraInfo = ChannelStore.getExtraInfo(this.props.channelId);
return {
- channel: ChannelStore.getCurrent(),
- memberChannel: ChannelStore.getCurrentMember(),
+ channel: ChannelStore.get(this.props.channelId),
+ memberChannel: ChannelStore.getMember(this.props.channelId),
users: extraInfo.members,
userCount: extraInfo.member_count,
searchVisible: SearchStore.getSearchResults() !== null,
@@ -105,7 +106,7 @@ export default class ChannelHeader extends React.Component {
});
const townsquare = ChannelStore.getByName('town-square');
- Utils.switchChannel(townsquare);
+ GlobalActions.emitChannelClickEvent(townsquare);
},
(err) => {
AsyncClient.dispatchError(err, 'handleLeave');
@@ -215,9 +216,9 @@ export default class ChannelHeader extends React.Component {
if (!isDirect) {
popoverListMembers = (
<PopoverListMembers
+ channel={channel}
members={this.state.users}
memberCount={this.state.userCount}
- channelId={channel.id}
/>
);
}
@@ -433,7 +434,10 @@ export default class ChannelHeader extends React.Component {
}
return (
- <div>
+ <div
+ id='channel-header'
+ className='channel-header'
+ >
<table className='channel-header alt'>
<tbody>
<tr>
@@ -518,4 +522,5 @@ export default class ChannelHeader extends React.Component {
}
ChannelHeader.propTypes = {
+ channelId: React.PropTypes.string.isRequired
};
diff --git a/webapp/components/channel_invite_button.jsx b/webapp/components/channel_invite_button.jsx
index e4af9f9ce..1fcd461ea 100644
--- a/webapp/components/channel_invite_button.jsx
+++ b/webapp/components/channel_invite_button.jsx
@@ -65,6 +65,7 @@ export default class ChannelInviteButton extends React.Component {
render() {
return (
<SpinnerButton
+ className='btn btn-sm btn-primary'
onClick={this.handleClick}
spinning={this.state.addingUser}
>
diff --git a/webapp/components/channel_notifications_modal.jsx b/webapp/components/channel_notifications_modal.jsx
index cc1162b77..564776876 100644
--- a/webapp/components/channel_notifications_modal.jsx
+++ b/webapp/components/channel_notifications_modal.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import {Modal} from 'react-bootstrap';
import SettingItemMin from './setting_item_min.jsx';
import SettingItemMax from './setting_item_max.jsx';
@@ -33,6 +34,7 @@ export default class ChannelNotificationsModal extends React.Component {
};
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
this.setState({activeSection: section});
}
componentWillReceiveProps(nextProps) {
diff --git a/webapp/components/channel_select.jsx b/webapp/components/channel_select.jsx
new file mode 100644
index 000000000..8622d1f57
--- /dev/null
+++ b/webapp/components/channel_select.jsx
@@ -0,0 +1,79 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import Constants from 'utils/constants.jsx';
+import ChannelStore from 'stores/channel_store.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+export default class ChannelSelect extends React.Component {
+ static get propTypes() {
+ return {
+ onChange: React.PropTypes.func,
+ value: React.PropTypes.string
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.handleChannelChange = this.handleChannelChange.bind(this);
+
+ this.state = {
+ channels: []
+ };
+ }
+
+ componentWillMount() {
+ this.setState({
+ channels: ChannelStore.getAll()
+ });
+
+ ChannelStore.addChangeListener(this.handleChannelChange);
+ }
+
+ componentWillUnmount() {
+ ChannelStore.removeChangeListener(this.handleChannelChange);
+ }
+
+ handleChannelChange() {
+ this.setState({
+ channels: ChannelStore.getAll()
+ });
+ }
+
+ render() {
+ const options = [
+ <option
+ key=''
+ value=''
+ >
+ {Utils.localizeMessage('channel_select.placeholder', '--- Select a channel ---')}
+ </option>
+ ];
+
+ this.state.channels.forEach((channel) => {
+ if (channel.type !== Constants.DM_CHANNEL) {
+ options.push(
+ <option
+ key={channel.id}
+ value={channel.id}
+ >
+ {channel.display_name}
+ </option>
+ );
+ }
+ });
+
+ return (
+ <select
+ className='form-control'
+ value={this.props.value}
+ onChange={this.props.onChange}
+ >
+ {options}
+ </select>
+ );
+ }
+}
diff --git a/webapp/components/channel_view.jsx b/webapp/components/channel_view.jsx
index 34e1666d0..4cca5aa98 100644
--- a/webapp/components/channel_view.jsx
+++ b/webapp/components/channel_view.jsx
@@ -1,14 +1,63 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import CenterPanel from 'components/center_panel.jsx';
-
import React from 'react';
+import ChannelHeader from 'components/channel_header.jsx';
+import PostsViewContainer from 'components/posts_view_container.jsx';
+import CreatePost from 'components/create_post.jsx';
+
+import ChannelStore from 'stores/channel_store.jsx';
+
export default class ChannelView extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.getStateFromStores = this.getStateFromStores.bind(this);
+ this.isStateValid = this.isStateValid.bind(this);
+ this.updateState = this.updateState.bind(this);
+
+ this.state = this.getStateFromStores(props);
+ }
+ getStateFromStores(props) {
+ const channel = ChannelStore.getByName(props.params.channel);
+ const channelId = channel ? channel.id : '';
+ return {
+ channelId
+ };
+ }
+ isStateValid() {
+ return this.state.channelId !== '';
+ }
+ updateState() {
+ this.setState(this.getStateFromStores(this.props));
+ }
+ componentDidMount() {
+ ChannelStore.addChangeListener(this.updateState);
+ }
+ componentWillUnmount() {
+ ChannelStore.removeChangeListener(this.updateState);
+ }
+ componentWillReceiveProps(nextProps) {
+ this.setState(this.getStateFromStores(nextProps));
+ }
render() {
return (
- <CenterPanel/>
+ <div
+ id='app-content'
+ className='app__content'
+ >
+ <ChannelHeader
+ channelId={this.state.channelId}
+ />
+ <PostsViewContainer profiles={this.props.profiles}/>
+ <div
+ className='post-create__container'
+ id='post-create'
+ >
+ <CreatePost/>
+ </div>
+ </div>
);
}
}
@@ -16,5 +65,6 @@ ChannelView.defaultProps = {
};
ChannelView.propTypes = {
- params: React.PropTypes.object
+ params: React.PropTypes.object.isRequired,
+ profiles: React.PropTypes.object
};
diff --git a/webapp/components/claim/components/email_to_ldap.jsx b/webapp/components/claim/components/email_to_ldap.jsx
index 1f51f9cd5..1ceb42a27 100644
--- a/webapp/components/claim/components/email_to_ldap.jsx
+++ b/webapp/components/claim/components/email_to_ldap.jsx
@@ -21,7 +21,7 @@ export default class EmailToLDAP extends React.Component {
e.preventDefault();
var state = {};
- const password = ReactDOM.findDOMNode(this.refs.password).value.trim();
+ const password = ReactDOM.findDOMNode(this.refs.emailpassword).value.trim();
if (!password) {
state.error = Utils.localizeMessage('claim.email_to_ldap.pwdError', 'Please enter your password.');
this.setState(state);
@@ -105,12 +105,18 @@ export default class EmailToLDAP extends React.Component {
}}
/>
</p>
+ <input
+ type='text'
+ style={{display: 'none'}}
+ name='fakeusernameremembered'
+ />
<div className={formClass}>
<input
type='password'
className='form-control'
- name='password'
- ref='password'
+ name='emailPassword'
+ ref='emailpassword'
+ autoComplete='off'
placeholder={Utils.localizeMessage('claim.email_to_ldap.pwd', 'Password')}
spellCheck='false'
/>
@@ -131,6 +137,7 @@ export default class EmailToLDAP extends React.Component {
className='form-control'
name='ldapId'
ref='ldapid'
+ autoComplete='off'
placeholder={Utils.localizeMessage('claim.email_to_ldap.ldapId', 'LDAP ID')}
spellCheck='false'
/>
@@ -141,6 +148,7 @@ export default class EmailToLDAP extends React.Component {
className='form-control'
name='ldapPassword'
ref='ldappassword'
+ autoComplete='off'
placeholder={Utils.localizeMessage('claim.email_to_ldap.ldapPwd', 'LDAP Password')}
spellCheck='false'
/>
diff --git a/webapp/components/error_page.jsx b/webapp/components/error_page.jsx
new file mode 100644
index 000000000..53f0fce82
--- /dev/null
+++ b/webapp/components/error_page.jsx
@@ -0,0 +1,58 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import $ from 'jquery';
+
+import React from 'react';
+import {Link} from 'react-router';
+
+import * as Utils from 'utils/utils.jsx';
+
+export default class ErrorPage extends React.Component {
+ componentDidMount() {
+ $('body').attr('class', 'sticky error');
+ }
+ componentWillUnmount() {
+ $('body').attr('class', '');
+ }
+ render() {
+ let title = this.props.location.query.title;
+ if (!title || title === '') {
+ title = Utils.localizeMessage('error.generic.title', 'Error');
+ }
+
+ let message = this.props.location.query.message;
+ if (!message || message === '') {
+ message = Utils.localizeMessage('error.generic.message', 'An error has occoured.');
+ }
+
+ let link = this.props.location.query.link;
+ if (!link || link === '') {
+ link = '/';
+ }
+
+ let linkMessage = this.props.location.query.linkmessage;
+ if (!linkMessage || linkMessage === '') {
+ linkMessage = Utils.localizeMessage('error.generic.link_message', 'Back to Mattermost');
+ }
+
+ return (
+ <div className='container-fluid'>
+ <div className='error__container'>
+ <div className='error__icon'>
+ <i className='fa fa-exclamation-triangle'/>
+ </div>
+ <h2>{title}</h2>
+ <p>{message}</p>
+ <Link to={link}>{linkMessage}</Link>
+ </div>
+ </div>
+ );
+ }
+}
+
+ErrorPage.defaultProps = {
+};
+ErrorPage.propTypes = {
+ location: React.PropTypes.object
+};
diff --git a/webapp/components/form_error.jsx b/webapp/components/form_error.jsx
new file mode 100644
index 000000000..b7d1de16a
--- /dev/null
+++ b/webapp/components/form_error.jsx
@@ -0,0 +1,50 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+export default class FormError extends React.Component {
+ static get propTypes() {
+ // accepts either a single error or an array of errors
+ return {
+ error: React.PropTypes.node,
+ errors: React.PropTypes.arrayOf(React.PropTypes.node)
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ error: null,
+ errors: []
+ };
+ }
+
+ render() {
+ if (!this.props.error && this.props.errors.length === 0) {
+ return null;
+ }
+
+ // look for the first truthy error to display
+ let message = this.props.error;
+
+ if (!message) {
+ for (const error of this.props.errors) {
+ if (error) {
+ message = error;
+ }
+ }
+ }
+
+ if (!message) {
+ return null;
+ }
+
+ return (
+ <div className='form-group has-error'>
+ <label className='control-label'>
+ {message}
+ </label>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/invite_member_modal.jsx b/webapp/components/invite_member_modal.jsx
index 1f8fd6133..81c3a9629 100644
--- a/webapp/components/invite_member_modal.jsx
+++ b/webapp/components/invite_member_modal.jsx
@@ -50,6 +50,7 @@ class InviteMemberModal extends React.Component {
constructor(props) {
super(props);
+ this.teamChange = this.teamChange.bind(this);
this.handleToggle = this.handleToggle.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleHide = this.handleHide.bind(this);
@@ -68,16 +69,27 @@ class InviteMemberModal extends React.Component {
emailEnabled: global.window.mm_config.SendEmailNotifications === 'true',
userCreationEnabled: global.window.mm_config.EnableUserCreation === 'true',
showConfirmModal: false,
- isSendingEmails: false
+ isSendingEmails: false,
+ teamType: null
};
}
+ teamChange() {
+ const team = TeamStore.getCurrent();
+ const teamType = team ? team.type : null;
+ this.setState({
+ teamType
+ });
+ }
+
componentDidMount() {
ModalStore.addModalListener(ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, this.handleToggle);
+ TeamStore.addChangeListener(this.teamChange);
}
componentWillUnmount() {
ModalStore.removeModalListener(ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, this.handleToggle);
+ TeamStore.removeChangeListener(this.teamChange);
}
handleToggle(value) {
@@ -224,7 +236,7 @@ class InviteMemberModal extends React.Component {
var currentUser = UserStore.getCurrentUser();
const {formatMessage} = this.props.intl;
- if (currentUser != null) {
+ if (currentUser != null && this.state.teamType != null) {
var inviteSections = [];
var inviteIds = this.state.inviteIds;
for (var i = 0; i < inviteIds.length; i++) {
@@ -398,7 +410,7 @@ class InviteMemberModal extends React.Component {
);
} else if (this.state.userCreationEnabled) {
var teamInviteLink = null;
- if (currentUser && TeamStore.getCurrent().type === 'O') {
+ if (currentUser && this.state.teamType === 'O') {
var link = (
<a
href='#'
diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx
index c6f7b50b1..0c4571083 100644
--- a/webapp/components/logged_in.jsx
+++ b/webapp/components/logged_in.jsx
@@ -10,13 +10,17 @@ import BrowserStore from 'stores/browser_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
+const TutorialSteps = Constants.TutorialSteps;
+const Preferences = Constants.Preferences;
import ErrorBar from 'components/error_bar.jsx';
import * as Websockets from 'action_creators/websocket_actions.jsx';
+import LoadingScreen from 'components/loading_screen.jsx';
import {browserHistory} from 'react-router';
import SidebarRight from 'components/sidebar_right.jsx';
import SidebarRightMenu from 'components/sidebar_right_menu.jsx';
+import Navbar from 'components/navbar.jsx';
// Modals
import GetPostLinkModal from 'components/get_post_link_modal.jsx';
@@ -41,6 +45,14 @@ export default class LoggedIn extends React.Component {
super(params);
this.onUserChanged = this.onUserChanged.bind(this);
+
+ this.state = {
+ user: null,
+ profiles: null
+ };
+ }
+ isValidState() {
+ return this.state.user != null && this.state.profiles != null;
}
onUserChanged() {
// Grab the current user
@@ -66,6 +78,20 @@ export default class LoggedIn extends React.Component {
Utils.applyTheme(Constants.THEMES.default);
}
}
+
+ // Go to tutorial if we are first arrivign
+ const tutorialStep = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 999);
+ if (tutorialStep <= TutorialSteps.INTRO_SCREENS) {
+ browserHistory.push(Utils.getTeamURLFromAddressBar() + '/tutorial');
+ }
+
+ // Get profiles
+ const profiles = UserStore.getProfiles();
+
+ this.setState({
+ user,
+ profiles
+ });
}
componentWillMount() {
// Emit view action
@@ -177,6 +203,8 @@ export default class LoggedIn extends React.Component {
Websockets.close();
UserStore.removeChangeListener(this.onUserChanged);
+ Utils.resetTheme();
+
$('body').off('click.userpopover');
$('body').off('mouseenter mouseleave', '.post');
$('body').off('mouseenter mouseleave', '.post.post--comment.same--root');
@@ -186,14 +214,46 @@ export default class LoggedIn extends React.Component {
$(window).off('keydown.preventBackspace');
}
render() {
+ if (!this.isValidState()) {
+ return <LoadingScreen/>;
+ }
+
+ let content = [];
+ if (this.props.children) {
+ content = this.props.children;
+ } else {
+ content.push(
+ this.props.navbar
+ );
+ content.push(
+ this.props.sidebar
+ );
+ content.push(
+ <div
+ key='inner-wrap'
+ className='inner-wrap channel__wrap'
+ >
+ <div className='row header'>
+ <div id='navbar'>
+ <Navbar/>
+ </div>
+ </div>
+ <div className='row main'>
+ {React.cloneElement(this.props.center, {
+ user: this.state.user,
+ profiles: this.state.profiles
+ })}
+ </div>
+ </div>
+ );
+ }
return (
<div className='channel-view'>
<ErrorBar/>
<div className='container-fluid'>
<SidebarRight/>
<SidebarRightMenu/>
- {this.props.sidebar}
- {this.props.center}
+ {content}
<GetPostLinkModal/>
<GetTeamInviteLinkModal/>
@@ -216,8 +276,12 @@ LoggedIn.defaultProps = {
};
LoggedIn.propTypes = {
- children: React.PropTypes.object,
- sidebar: React.PropTypes.object,
- center: React.PropTypes.object,
+ children: React.PropTypes.oneOfType([
+ React.PropTypes.arrayOf(React.PropTypes.element),
+ React.PropTypes.element
+ ]),
+ navbar: React.PropTypes.element,
+ sidebar: React.PropTypes.element,
+ center: React.PropTypes.element,
params: React.PropTypes.object
};
diff --git a/webapp/components/login_email.jsx b/webapp/components/login/components/login_email.jsx
index d54c32ff9..b1f484c08 100644
--- a/webapp/components/login_email.jsx
+++ b/webapp/components/login/components/login_email.jsx
@@ -2,69 +2,40 @@
// See License.txt for license information.
import * as Utils from 'utils/utils.jsx';
-import * as Client from 'utils/client.jsx';
import UserStore from 'stores/user_store.jsx';
-import {browserHistory} from 'react-router';
+import Constants from 'utils/constants.jsx';
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl';
-
-var holders = defineMessages({
- badTeam: {
- id: 'login_email.badTeam',
- defaultMessage: 'Bad team name'
- },
- emailReq: {
- id: 'login_email.emailReq',
- defaultMessage: 'An email is required'
- },
- pwdReq: {
- id: 'login_email.pwdReq',
- defaultMessage: 'A password is required'
- },
- email: {
- id: 'login_email.email',
- defaultMessage: 'Email'
- },
- pwd: {
- id: 'login_email.pwd',
- defaultMessage: 'Password'
- }
-});
+import {FormattedMessage} from 'react-intl';
import React from 'react';
-class LoginEmail extends React.Component {
+export default class LoginEmail extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
- serverError: ''
+ serverError: props.serverError
};
}
+ componentWillReceiveProps(nextProps) {
+ this.setState({serverError: nextProps.serverError});
+ }
handleSubmit(e) {
e.preventDefault();
- const {formatMessage} = this.props.intl;
var state = {};
- const name = this.props.teamName;
- if (!name) {
- state.serverError = formatMessage(holders.badTeam);
- this.setState(state);
- return;
- }
-
const email = this.refs.email.value.trim();
if (!email) {
- state.serverError = formatMessage(holders.emailReq);
+ state.serverError = Utils.localizeMessage('login_email.emailReq', 'An email is required');
this.setState(state);
return;
}
const password = this.refs.password.value.trim();
if (!password) {
- state.serverError = formatMessage(holders.pwdReq);
+ state.serverError = Utils.localizeMessage('login_email.pwdReq', 'A password is required');
this.setState(state);
return;
}
@@ -72,21 +43,7 @@ class LoginEmail extends React.Component {
state.serverError = '';
this.setState(state);
- Client.loginByEmail(name, email, password,
- () => {
- UserStore.setLastEmail(email);
- browserHistory.push('/' + name + '/channels/town-square');
- },
- (err) => {
- if (err.id === 'api.user.login.not_verified.app_error') {
- browserHistory.push('/verify_email?teamname=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email));
- return;
- }
- state.serverError = err.message;
- this.valid = false;
- this.setState(state);
- }
- );
+ this.props.submit(Constants.EMAIL_SERVICE, email, password);
}
render() {
let serverError;
@@ -110,7 +67,6 @@ class LoginEmail extends React.Component {
priorEmail = decodeURIComponent(emailParam);
}
- const {formatMessage} = this.props.intl;
return (
<form onSubmit={this.handleSubmit}>
<div className='signup__email-container'>
@@ -125,7 +81,7 @@ class LoginEmail extends React.Component {
name='email'
defaultValue={priorEmail}
ref='email'
- placeholder={formatMessage(holders.email)}
+ placeholder={Utils.localizeMessage('login_email.email', 'Email')}
spellCheck='false'
/>
</div>
@@ -136,7 +92,7 @@ class LoginEmail extends React.Component {
className='form-control'
name='password'
ref='password'
- placeholder={formatMessage(holders.pwd)}
+ placeholder={Utils.localizeMessage('login_email.pwd', 'Password')}
spellCheck='false'
/>
</div>
@@ -160,8 +116,6 @@ LoginEmail.defaultProps = {
};
LoginEmail.propTypes = {
- intl: intlShape.isRequired,
- teamName: React.PropTypes.string.isRequired
+ submit: React.PropTypes.func.isRequired,
+ serverError: React.PropTypes.string
};
-
-export default injectIntl(LoginEmail);
diff --git a/webapp/components/login_ldap.jsx b/webapp/components/login/components/login_ldap.jsx
index 59ff973dc..a2013710f 100644
--- a/webapp/components/login_ldap.jsx
+++ b/webapp/components/login/components/login_ldap.jsx
@@ -2,68 +2,39 @@
// See License.txt for license information.
import * as Utils from 'utils/utils.jsx';
-import * as Client from 'utils/client.jsx';
+import Constants from 'utils/constants.jsx';
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl';
-import {browserHistory} from 'react-router';
-
-const holders = defineMessages({
- badTeam: {
- id: 'login_ldap.badTeam',
- defaultMessage: 'Bad team name'
- },
- idReq: {
- id: 'login_ldap.idlReq',
- defaultMessage: 'An LDAP ID is required'
- },
- pwdReq: {
- id: 'login_ldap.pwdReq',
- defaultMessage: 'An LDAP password is required'
- },
- username: {
- id: 'login_ldap.username',
- defaultMessage: 'LDAP Username'
- },
- pwd: {
- id: 'login_ldap.pwd',
- defaultMessage: 'LDAP Password'
- }
-});
+import {FormattedMessage} from 'react-intl';
import React from 'react';
-class LoginLdap extends React.Component {
+export default class LoginLdap extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
- serverError: ''
+ serverError: props.serverError
};
}
+ componentWillReceiveProps(nextProps) {
+ this.setState({serverError: nextProps.serverError});
+ }
handleSubmit(e) {
e.preventDefault();
- const {formatMessage} = this.props.intl;
- var state = {};
-
- const teamName = this.props.teamName;
- if (!teamName) {
- state.serverError = formatMessage(holders.badTeam);
- this.setState(state);
- return;
- }
+ const state = {};
const id = this.refs.id.value.trim();
if (!id) {
- state.serverError = formatMessage(holders.idReq);
+ state.serverError = Utils.localizeMessage('login_ldap.idlReq', 'An LDAP ID is required');
this.setState(state);
return;
}
const password = this.refs.password.value.trim();
if (!password) {
- state.serverError = formatMessage(holders.pwdReq);
+ state.serverError = Utils.localizeMessage('login_ldap.pwdReq', 'An LDAP password is required');
this.setState(state);
return;
}
@@ -71,20 +42,7 @@ class LoginLdap extends React.Component {
state.serverError = '';
this.setState(state);
- Client.loginByLdap(teamName, id, password,
- () => {
- const redirect = Utils.getUrlParameter('redirect');
- if (redirect) {
- browserHistory.push(decodeURIComponent(redirect));
- } else {
- browserHistory.push('/' + teamName + '/channels/town-square');
- }
- },
- (err) => {
- state.serverError = err.message;
- this.setState(state);
- }
- );
+ this.props.submit(Constants.LDAP_SERVICE, id, password);
}
render() {
let serverError;
@@ -93,7 +51,7 @@ class LoginLdap extends React.Component {
serverError = <label className='control-label'>{this.state.serverError}</label>;
errorClass = ' has-error';
}
- const {formatMessage} = this.props.intl;
+
return (
<form onSubmit={this.handleSubmit}>
<div className='signup__email-container'>
@@ -105,7 +63,7 @@ class LoginLdap extends React.Component {
autoFocus={true}
className='form-control'
ref='id'
- placeholder={formatMessage(holders.username)}
+ placeholder={Utils.localizeMessage('login_ldap.username', 'LDAP Username')}
spellCheck='false'
/>
</div>
@@ -114,7 +72,7 @@ class LoginLdap extends React.Component {
type='password'
className='form-control'
ref='password'
- placeholder={formatMessage(holders.pwd)}
+ placeholder={Utils.localizeMessage('login_ldap.pwd', 'LDAP Password')}
spellCheck='false'
/>
</div>
@@ -138,8 +96,6 @@ LoginLdap.defaultProps = {
};
LoginLdap.propTypes = {
- intl: intlShape.isRequired,
- teamName: React.PropTypes.string.isRequired
+ serverError: React.PropTypes.string,
+ submit: React.PropTypes.func.isRequired
};
-
-export default injectIntl(LoginLdap);
diff --git a/webapp/components/login/components/login_mfa.jsx b/webapp/components/login/components/login_mfa.jsx
new file mode 100644
index 000000000..f8ebf1e82
--- /dev/null
+++ b/webapp/components/login/components/login_mfa.jsx
@@ -0,0 +1,92 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import * as Utils from 'utils/utils.jsx';
+
+import {FormattedMessage} from 'react-intl';
+
+import React from 'react';
+
+export default class LoginMfa extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+
+ this.state = {
+ serverError: ''
+ };
+ }
+ handleSubmit(e) {
+ e.preventDefault();
+ const state = {};
+
+ const token = this.refs.token.value.trim();
+ if (!token) {
+ state.serverError = Utils.localizeMessage('login_mfa.tokenReq', 'Please enter an MFA token');
+ this.setState(state);
+ return;
+ }
+
+ state.serverError = '';
+ this.setState(state);
+
+ this.props.submit(this.props.method, this.props.loginId, this.props.password, token);
+ }
+ render() {
+ let serverError;
+ let errorClass = '';
+ if (this.state.serverError) {
+ serverError = <label className='control-label'>{this.state.serverError}</label>;
+ errorClass = ' has-error';
+ }
+
+ return (
+ <form onSubmit={this.handleSubmit}>
+ <div className='signup__email-container'>
+ <p>
+ <FormattedMessage
+ id='login_mfa.enterToken'
+ defaultMessage="To complete the sign in process, please enter a token from your smartphone's authenticator"
+ />
+ </p>
+ <div className={'form-group' + errorClass}>
+ {serverError}
+ </div>
+ <div className={'form-group' + errorClass}>
+ <input
+ type='text'
+ className='form-control'
+ name='token'
+ ref='token'
+ placeholder={Utils.localizeMessage('login_mfa.token', 'MFA Token')}
+ spellCheck='false'
+ autoComplete='off'
+ autoFocus={true}
+ />
+ </div>
+ <div className='form-group'>
+ <button
+ type='submit'
+ className='btn btn-primary'
+ >
+ <FormattedMessage
+ id='login_mfa.submit'
+ defaultMessage='Submit'
+ />
+ </button>
+ </div>
+ </div>
+ </form>
+ );
+ }
+}
+LoginMfa.defaultProps = {
+};
+
+LoginMfa.propTypes = {
+ method: React.PropTypes.string.isRequired,
+ loginId: React.PropTypes.string.isRequired,
+ password: React.PropTypes.string.isRequired,
+ submit: React.PropTypes.func.isRequired
+};
diff --git a/webapp/components/login_username.jsx b/webapp/components/login/components/login_username.jsx
index 71874fa1a..3cb213994 100644
--- a/webapp/components/login_username.jsx
+++ b/webapp/components/login/components/login_username.jsx
@@ -2,42 +2,10 @@
// See License.txt for license information.
import * as Utils from 'utils/utils.jsx';
-import * as Client from 'utils/client.jsx';
import UserStore from 'stores/user_store.jsx';
+import Constants from 'utils/constants.jsx';
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl';
-import {browserHistory} from 'react-router';
-
-var holders = defineMessages({
- badTeam: {
- id: 'login_username.badTeam',
- defaultMessage: 'Bad team name'
- },
- usernameReq: {
- id: 'login_username.usernameReq',
- defaultMessage: 'A username is required'
- },
- pwdReq: {
- id: 'login_username.pwdReq',
- defaultMessage: 'A password is required'
- },
- verifyEmailError: {
- id: 'login_username.verifyEmailError',
- defaultMessage: 'Please verify your email address. Check your inbox for an email.'
- },
- userNotFoundError: {
- id: 'login_username.userNotFoundError',
- defaultMessage: "We couldn't find an existing account matching your username for this team."
- },
- username: {
- id: 'login_username.username',
- defaultMessage: 'Username'
- },
- pwd: {
- id: 'login_username.pwd',
- defaultMessage: 'Password'
- }
-});
+import {FormattedMessage} from 'react-intl';
import React from 'react';
@@ -48,31 +16,26 @@ export default class LoginUsername extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
- serverError: ''
+ serverError: props.serverError
};
}
+ componentWillReceiveProps(nextProps) {
+ this.setState({serverError: nextProps.serverError});
+ }
handleSubmit(e) {
e.preventDefault();
- const {formatMessage} = this.props.intl;
- var state = {};
-
- const name = this.props.teamName;
- if (!name) {
- state.serverError = formatMessage(holders.badTeam);
- this.setState(state);
- return;
- }
+ const state = {};
const username = this.refs.username.value.trim();
if (!username) {
- state.serverError = formatMessage(holders.usernameReq);
+ state.serverError = Utils.localizeMessage('login_username.usernameReq', 'A username is required');
this.setState(state);
return;
}
const password = this.refs.password.value.trim();
if (!password) {
- state.serverError = formatMessage(holders.pwdReq);
+ state.serverError = Utils.localizeMessage('login_username.pwdReq', 'A password is required');
this.setState(state);
return;
}
@@ -80,30 +43,7 @@ export default class LoginUsername extends React.Component {
state.serverError = '';
this.setState(state);
- Client.loginByUsername(name, username, password,
- () => {
- UserStore.setLastUsername(username);
-
- const redirect = Utils.getUrlParameter('redirect');
- if (redirect) {
- browserHistory.push(decodeURIComponent(redirect));
- } else {
- browserHistory.push('/' + name + '/channels/town-square');
- }
- },
- (err) => {
- if (err.id === 'api.user.login.not_verified.app_error') {
- state.serverError = formatMessage(holders.verifyEmailError);
- } else if (err.id === 'store.sql_user.get_by_username.app_error') {
- state.serverError = formatMessage(holders.userNotFoundError);
- } else {
- state.serverError = err.message;
- }
-
- this.valid = false;
- this.setState(state);
- }
- );
+ this.props.submit(Constants.USERNAME_SERVICE, username, password);
}
render() {
let serverError;
@@ -127,7 +67,6 @@ export default class LoginUsername extends React.Component {
priorUsername = decodeURIComponent(emailParam);
}
- const {formatMessage} = this.props.intl;
return (
<form onSubmit={this.handleSubmit}>
<div className='signup__email-container'>
@@ -142,7 +81,7 @@ export default class LoginUsername extends React.Component {
name='username'
defaultValue={priorUsername}
ref='username'
- placeholder={formatMessage(holders.username)}
+ placeholder={Utils.localizeMessage('login_username.username', 'Username')}
spellCheck='false'
/>
</div>
@@ -153,7 +92,7 @@ export default class LoginUsername extends React.Component {
className='form-control'
name='password'
ref='password'
- placeholder={formatMessage(holders.pwd)}
+ placeholder={Utils.localizeMessage('login_username.pwd', 'Password')}
spellCheck='false'
/>
</div>
@@ -177,8 +116,6 @@ LoginUsername.defaultProps = {
};
LoginUsername.propTypes = {
- intl: intlShape.isRequired,
- teamName: React.PropTypes.string.isRequired
+ serverError: React.PropTypes.string,
+ submit: React.PropTypes.func.isRequired
};
-
-export default injectIntl(LoginUsername);
diff --git a/webapp/components/login.jsx b/webapp/components/login/login.jsx
index e8180895d..e867af47a 100644
--- a/webapp/components/login.jsx
+++ b/webapp/components/login/login.jsx
@@ -1,14 +1,17 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import LoginEmail from './login_email.jsx';
-import LoginUsername from './login_username.jsx';
-import LoginLdap from './login_ldap.jsx';
+import LoginEmail from './components/login_email.jsx';
+import LoginUsername from './components/login_username.jsx';
+import LoginLdap from './components/login_ldap.jsx';
+import LoginMfa from './components/login_mfa.jsx';
+
+import TeamStore from 'stores/team_store.jsx';
+import UserStore from 'stores/user_store.jsx';
-import * as Utils from 'utils/utils.jsx';
import * as Client from 'utils/client.jsx';
+import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
-import TeamStore from 'stores/team_store.jsx';
import {FormattedMessage} from 'react-intl';
import {browserHistory, Link} from 'react-router';
@@ -21,8 +24,12 @@ export default class Login extends React.Component {
this.getStateFromStores = this.getStateFromStores.bind(this);
this.onTeamChange = this.onTeamChange.bind(this);
+ this.preSubmit = this.preSubmit.bind(this);
+ this.submit = this.submit.bind(this);
- this.state = this.getStateFromStores();
+ const state = this.getStateFromStores();
+ state.doneCheckLogin = false;
+ this.state = state;
}
componentDidMount() {
TeamStore.addChangeListener(this.onTeamChange);
@@ -39,61 +46,95 @@ export default class Login extends React.Component {
}
getStateFromStores() {
return {
- currentTeam: TeamStore.getByName(this.props.params.team),
- doneCheckLogin: false
+ currentTeam: TeamStore.getByName(this.props.params.team)
};
}
onTeamChange() {
this.setState(this.getStateFromStores());
}
- render() {
- const currentTeam = this.state.currentTeam;
- if (currentTeam == null || !this.state.doneCheckLogin) {
- return <div/>;
+ preSubmit(method, loginId, password) {
+ if (global.window.mm_config.EnableMultifactorAuthentication !== 'true') {
+ this.submit(method, loginId, password, '');
+ return;
}
- const teamDisplayName = currentTeam.display_name;
- const teamName = currentTeam.name;
- const ldapEnabled = global.window.mm_config.EnableLdap === 'true';
- const usernameSigninEnabled = global.window.mm_config.EnableSignInWithUsername === 'true';
+ Client.checkMfa(method, this.state.currentTeam.name, loginId,
+ (data) => {
+ if (data.mfa_required === 'true') {
+ this.setState({showMfa: true, method, loginId, password});
+ } else {
+ this.submit(method, loginId, password, '');
+ }
+ },
+ (err) => {
+ if (method === Constants.EMAIL_SERVICE) {
+ this.setState({serverEmailError: err.message});
+ } else if (method === Constants.USERNAME_SERVICE) {
+ this.setState({serverUsernameError: err.message});
+ } else if (method === Constants.LDAP_SERVICE) {
+ this.setState({serverLdapError: err.message});
+ }
+ }
+ );
+ }
+ submit(method, loginId, password, token) {
+ this.setState({showMfa: false, serverEmailError: null, serverUsernameError: null, serverLdapError: null});
- let loginMessage = [];
- if (global.window.mm_config.EnableSignUpWithGitLab === 'true') {
- loginMessage.push(
- <Link
- className='btn btn-custom-login gitlab'
- key='gitlab'
- to={'/api/v1/oauth/gitlab/login?team=' + encodeURIComponent(teamName)}
- >
- <span className='icon'/>
- <span>
- <FormattedMessage
- id='login.gitlab'
- defaultMessage='with GitLab'
- />
- </span>
- </Link>
+ const team = this.state.currentTeam.name;
+
+ if (method === Constants.EMAIL_SERVICE) {
+ Client.loginByEmail(team, loginId, password, token,
+ () => {
+ UserStore.setLastEmail(loginId);
+ browserHistory.push('/' + team + '/channels/town-square');
+ },
+ (err) => {
+ if (err.id === 'api.user.login.not_verified.app_error') {
+ browserHistory.push('/verify_email?teamname=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(loginId));
+ return;
+ }
+ this.setState({serverEmailError: err.message});
+ }
);
- }
+ } else if (method === Constants.USERNAME_SERVICE) {
+ Client.loginByUsername(team, loginId, password, token,
+ () => {
+ UserStore.setLastUsername(loginId);
- if (global.window.mm_config.EnableSignUpWithGoogle === 'true') {
- loginMessage.push(
- <Link
- className='btn btn-custom-login google'
- key='google'
- to={'/api/v1/oauth/google/login?team=' + encodeURIComponent(teamName)}
- >
- <span className='icon'/>
- <span>
- <FormattedMessage
- id='login.google'
- defaultMessage='with Google Apps'
- />
- </span>
- </Link>
+ const redirect = Utils.getUrlParameter('redirect');
+ if (redirect) {
+ browserHistory.push(decodeURIComponent(redirect));
+ } else {
+ browserHistory.push('/' + team + '/channels/town-square');
+ }
+ },
+ (err) => {
+ if (err.id === 'api.user.login.not_verified.app_error') {
+ this.setState({serverUsernameError: Utils.localizeMessage('login_username.verifyEmailError', 'Please verify your email address. Check your inbox for an email.')});
+ } else if (err.id === 'store.sql_user.get_by_username.app_error') {
+ this.setState({serverUsernameError: Utils.localizeMessage('login_username.userNotFoundError', 'We couldn\'t find an existing account matching your username for this team.')});
+ } else {
+ this.setState({serverUsernameError: err.message});
+ }
+ }
+ );
+ } else if (method === Constants.LDAP_SERVICE) {
+ Client.loginByLdap(team, loginId, password, token,
+ () => {
+ const redirect = Utils.getUrlParameter('redirect');
+ if (redirect) {
+ browserHistory.push(decodeURIComponent(redirect));
+ } else {
+ browserHistory.push('/' + team + '/channels/town-square');
+ }
+ },
+ (err) => {
+ this.setState({serverLdapError: err.message});
+ }
);
}
-
+ }
+ createLoginOptions(currentTeam) {
const extraParam = Utils.getUrlParameter('extra');
let extraBox = '';
if (extraParam) {
@@ -130,44 +171,126 @@ export default class Login extends React.Component {
}
}
- let emailSignup;
- if (global.window.mm_config.EnableSignInWithEmail === 'true') {
- emailSignup = (
- <LoginEmail
- teamName={teamName}
- />
+ const teamName = currentTeam.name;
+ const ldapEnabled = global.window.mm_config.EnableLdap === 'true';
+ const gitlabSigninEnabled = global.window.mm_config.EnableSignUpWithGitLab === 'true';
+ const googleSigninEnabled = global.window.mm_config.EnableSignUpWithGoogle === 'true';
+ const usernameSigninEnabled = global.window.mm_config.EnableSignInWithUsername === 'true';
+ const emailSigninEnabled = global.window.mm_config.EnableSignInWithEmail === 'true';
+
+ const oauthLogins = [];
+ if (gitlabSigninEnabled) {
+ oauthLogins.push(
+ <Link
+ className='btn btn-custom-login gitlab'
+ key='gitlab'
+ to={'/api/v1/oauth/gitlab/login?team=' + encodeURIComponent(teamName)}
+ >
+ <span className='icon'/>
+ <span>
+ <FormattedMessage
+ id='login.gitlab'
+ defaultMessage='with GitLab'
+ />
+ </span>
+ </Link>
);
}
- if (loginMessage.length > 0 && emailSignup) {
- loginMessage = (
- <div>
- {loginMessage}
- <div className='or__container'>
+ if (googleSigninEnabled) {
+ oauthLogins.push(
+ <Link
+ className='btn btn-custom-login google'
+ key='google'
+ to={'/api/v1/oauth/google/login?team=' + encodeURIComponent(teamName)}
+ >
+ <span className='icon'/>
+ <span>
<FormattedMessage
- id='login.or'
- defaultMessage='or'
+ id='login.google'
+ defaultMessage='with Google Apps'
/>
+ </span>
+ </Link>
+ );
+ }
+
+ let emailLogin;
+ if (emailSigninEnabled) {
+ emailLogin = (
+ <LoginEmail
+ teamName={teamName}
+ serverError={this.state.serverEmailError}
+ submit={this.preSubmit}
+ />
+ );
+
+ if (oauthLogins.length > 0) {
+ emailLogin = (
+ <div>
+ <div className='or__container'>
+ <FormattedMessage
+ id='login.or'
+ defaultMessage='or'
+ />
+ </div>
+ {emailLogin}
</div>
- </div>
+ );
+ }
+ }
+
+ let usernameLogin;
+ if (usernameSigninEnabled) {
+ usernameLogin = (
+ <LoginUsername
+ teamName={teamName}
+ serverError={this.state.serverUsernameError}
+ submit={this.preSubmit}
+ />
);
+
+ if (emailSigninEnabled || oauthLogins.length > 0) {
+ usernameLogin = (
+ <div>
+ <div className='or__container'>
+ <FormattedMessage
+ id='login.or'
+ defaultMessage='or'
+ />
+ </div>
+ {usernameLogin}
+ </div>
+ );
+ }
}
- let forgotPassword;
- if (emailSignup) {
- forgotPassword = (
- <div className='form-group'>
- <Link to={'/' + teamName + '/reset_password'}>
- <FormattedMessage
- id='login.forgot'
- defaultMessage='I forgot my password'
- />
- </Link>
- </div>
+ let ldapLogin;
+ if (ldapEnabled) {
+ ldapLogin = (
+ <LoginLdap
+ teamName={teamName}
+ serverError={this.state.serverLdapError}
+ submit={this.preSubmit}
+ />
);
+
+ if (emailSigninEnabled || usernameSigninEnabled || oauthLogins.length > 0) {
+ ldapLogin = (
+ <div>
+ <div className='or__container'>
+ <FormattedMessage
+ id='login.or'
+ defaultMessage='or'
+ />
+ </div>
+ {ldapLogin}
+ </div>
+ );
+ }
}
- let userSignUp = null;
+ let userSignUp;
if (currentTeam.allow_open_invite) {
userSignUp = (
<div>
@@ -190,7 +313,21 @@ export default class Login extends React.Component {
);
}
- let teamSignUp = null;
+ let forgotPassword;
+ if (usernameSigninEnabled || emailSigninEnabled) {
+ forgotPassword = (
+ <div className='form-group'>
+ <Link to={'/' + teamName + '/reset_password'}>
+ <FormattedMessage
+ id='login.forgot'
+ defaultMessage='I forgot my password'
+ />
+ </Link>
+ </div>
+ );
+ }
+
+ let teamSignUp;
if (global.window.mm_config.EnableTeamCreation === 'true' && !Utils.isMobileApp()) {
teamSignUp = (
<div className='margin--extra'>
@@ -207,54 +344,37 @@ export default class Login extends React.Component {
);
}
- let ldapLogin = null;
- if (global.window.mm_config.EnableLdap === 'true') {
- ldapLogin = (
- <LoginLdap
- teamName={teamName}
- />
- );
- }
-
- if (ldapEnabled && (loginMessage.length > 0 || emailSignup || usernameSigninEnabled)) {
- ldapLogin = (
- <div>
- <div className='or__container'>
- <FormattedMessage
- id='login.or'
- defaultMessage='or'
- />
- </div>
- <LoginLdap
- teamName={teamName}
- />
- </div>
- );
+ return (
+ <div>
+ {extraBox}
+ {oauthLogins}
+ {emailLogin}
+ {usernameLogin}
+ {ldapLogin}
+ {userSignUp}
+ {forgotPassword}
+ {teamSignUp}
+ </div>
+ );
+ }
+ render() {
+ const currentTeam = this.state.currentTeam;
+ if (currentTeam == null || !this.state.doneCheckLogin) {
+ return <div/>;
}
- let usernameLogin = null;
- if (global.window.mm_config.EnableSignInWithUsername === 'true') {
- usernameLogin = (
- <LoginUsername
- teamName={teamName}
+ let content;
+ if (this.state.showMfa) {
+ content = (
+ <LoginMfa
+ method={this.state.method}
+ loginId={this.state.loginId}
+ password={this.state.password}
+ submit={this.submit}
/>
);
- }
-
- if (usernameSigninEnabled && (loginMessage.length > 0 || emailSignup || ldapEnabled)) {
- usernameLogin = (
- <div>
- <div className='or__container'>
- <FormattedMessage
- id='login.or'
- defaultMessage='or'
- />
- </div>
- <LoginUsername
- teamName={teamName}
- />
- </div>
- );
+ } else {
+ content = this.createLoginOptions(currentTeam);
}
return (
@@ -275,7 +395,7 @@ export default class Login extends React.Component {
defaultMessage='Sign in to:'
/>
</h5>
- <h2 className='signup-team__name'>{teamDisplayName}</h2>
+ <h2 className='signup-team__name'>{currentTeam.display_name}</h2>
<h2 className='signup-team__subdomain'>
<FormattedMessage
id='login.on'
@@ -285,14 +405,7 @@ export default class Login extends React.Component {
}}
/>
</h2>
- {extraBox}
- {loginMessage}
- {emailSignup}
- {usernameLogin}
- {ldapLogin}
- {userSignUp}
- {forgotPassword}
- {teamSignUp}
+ {content}
</div>
</div>
</div>
diff --git a/webapp/components/more_channels.jsx b/webapp/components/more_channels.jsx
index d0eeec1ef..811bb8101 100644
--- a/webapp/components/more_channels.jsx
+++ b/webapp/components/more_channels.jsx
@@ -9,6 +9,7 @@ import * as AsyncClient from 'utils/async_client.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import LoadingScreen from './loading_screen.jsx';
import NewChannelFlow from './new_channel_flow.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import {FormattedMessage} from 'react-intl';
@@ -64,8 +65,7 @@ export default class MoreChannels extends React.Component {
client.joinChannel(channel.id,
() => {
$(ReactDOM.findDOMNode(this.refs.modal)).modal('hide');
- AsyncClient.getChannel(channel.id);
- Utils.switchChannel(channel);
+ GlobalActions.emitChannelClickEvent(channel);
this.setState({joiningChannel: -1});
},
(err) => {
diff --git a/webapp/components/more_direct_channels.jsx b/webapp/components/more_direct_channels.jsx
index d1446059d..29d64517e 100644
--- a/webapp/components/more_direct_channels.jsx
+++ b/webapp/components/more_direct_channels.jsx
@@ -5,6 +5,7 @@ import {Modal} from 'react-bootstrap';
import FilteredUserList from './filtered_user_list.jsx';
import UserStore from 'stores/user_store.jsx';
import * as Utils from 'utils/utils.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import {FormattedMessage} from 'react-intl';
import SpinnerButton from 'components/spinner_button.jsx';
@@ -68,7 +69,7 @@ export default class MoreDirectChannels extends React.Component {
Utils.openDirectChannelToUser(
teammate,
(channel) => {
- Utils.switchChannel(channel);
+ GlobalActions.emitChannelClickEvent(channel);
this.setState({loadingDMChannel: -1});
this.handleHide();
},
@@ -85,6 +86,7 @@ export default class MoreDirectChannels extends React.Component {
createJoinDirectChannelButton({user}) {
return (
<SpinnerButton
+ className='btn btm-sm btn-primary'
spinning={this.state.loadingDMChannel === user.id}
onClick={this.handleShowDirectChannel.bind(this, user)}
>
diff --git a/webapp/components/msg_typing.jsx b/webapp/components/msg_typing.jsx
index b2d414287..631eea78d 100644
--- a/webapp/components/msg_typing.jsx
+++ b/webapp/components/msg_typing.jsx
@@ -40,13 +40,15 @@ class MsgTyping extends React.Component {
}
updateTypingText(typingUsers) {
- if (!typingUsers) {
- return;
+ let text = '';
+ let users = {};
+ let numUsers = 0;
+ if (typingUsers) {
+ users = Object.keys(typingUsers);
+ numUsers = users.length;
}
- const users = Object.keys(typingUsers);
- let text = '';
- switch (users.length) {
+ switch (numUsers) {
case 0:
text = '';
break;
diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx
index e58e142d0..5afd7e683 100644
--- a/webapp/components/navbar.jsx
+++ b/webapp/components/navbar.jsx
@@ -45,6 +45,7 @@ export default class Navbar extends React.Component {
this.showEditChannelHeaderModal = this.showEditChannelHeaderModal.bind(this);
this.showRenameChannelModal = this.showRenameChannelModal.bind(this);
this.hideRenameChannelModal = this.hideRenameChannelModal.bind(this);
+ this.isStateValid = this.isStateValid.bind(this);
this.createCollapseButtons = this.createCollapseButtons.bind(this);
this.createDropdown = this.createDropdown.bind(this);
@@ -64,7 +65,7 @@ export default class Navbar extends React.Component {
currentUser: UserStore.getCurrentUser()
};
}
- stateValid() {
+ isStateValid() {
return this.state.channel && this.state.member && this.state.users && this.state.currentUser;
}
componentDidMount() {
@@ -422,7 +423,7 @@ export default class Navbar extends React.Component {
return buttons;
}
render() {
- if (!this.stateValid()) {
+ if (!this.isStateValid()) {
return null;
}
diff --git a/webapp/components/navbar_dropdown.jsx b/webapp/components/navbar_dropdown.jsx
index 7e42a71ea..da1ae237e 100644
--- a/webapp/components/navbar_dropdown.jsx
+++ b/webapp/components/navbar_dropdown.jsx
@@ -59,6 +59,7 @@ export default class NavbarDropdown extends React.Component {
var isAdmin = false;
var isSystemAdmin = false;
var teamSettings = null;
+ let integrationsLink = null;
if (currentUser != null) {
isAdmin = Utils.isAdmin(currentUser.roles);
@@ -125,6 +126,21 @@ export default class NavbarDropdown extends React.Component {
);
}
+ if (window.mm_config.EnableIncomingWebhooks === 'true' || window.mm_config.EnableOutgoingWebhooks === 'true') {
+ if (isAdmin || window.EnableAdminOnlyIntegrations !== 'true') {
+ integrationsLink = (
+ <li>
+ <Link to={'/settings/integrations'}>
+ <FormattedMessage
+ id='navbar_dropdown.integrations'
+ defaultMessage='Integrations'
+ />
+ </Link>
+ </li>
+ );
+ }
+ }
+
if (isSystemAdmin) {
sysAdminLink = (
<li>
@@ -238,6 +254,7 @@ export default class NavbarDropdown extends React.Component {
</li>
{adminDivider}
{teamSettings}
+ {integrationsLink}
{manageLink}
{sysAdminLink}
{teams}
diff --git a/webapp/components/new_channel_flow.jsx b/webapp/components/new_channel_flow.jsx
index 30035ee5d..8c66ef3ce 100644
--- a/webapp/components/new_channel_flow.jsx
+++ b/webapp/components/new_channel_flow.jsx
@@ -2,9 +2,9 @@
// See License.txt for license information.
import * as Utils from 'utils/utils.jsx';
-import * as AsyncClient from 'utils/async_client.jsx';
import * as Client from 'utils/client.jsx';
import UserStore from 'stores/user_store.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import NewChannelModal from './new_channel_modal.jsx';
import ChangeURLModal from './change_url_modal.jsx';
@@ -110,8 +110,7 @@ class NewChannelFlow extends React.Component {
Client.createChannel(channel,
(data) => {
this.props.onModalDismissed();
- AsyncClient.getChannel(data.id);
- Utils.switchChannel(data);
+ GlobalActions.emitChannelClickEvent(data);
},
(err) => {
if (err.id === 'model.channel.is_valid.2_or_more.app_error') {
@@ -247,4 +246,4 @@ NewChannelFlow.propTypes = {
onModalDismissed: React.PropTypes.func.isRequired
};
-export default injectIntl(NewChannelFlow); \ No newline at end of file
+export default injectIntl(NewChannelFlow);
diff --git a/webapp/components/permalink_view.jsx b/webapp/components/permalink_view.jsx
new file mode 100644
index 000000000..2c32d643d
--- /dev/null
+++ b/webapp/components/permalink_view.jsx
@@ -0,0 +1,93 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import ChannelHeader from 'components/channel_header.jsx';
+import PostFocusView from 'components/post_focus_view.jsx';
+
+import ChannelStore from 'stores/channel_store.jsx';
+import TeamStore from 'stores/team_store.jsx';
+
+import {Link} from 'react-router';
+import {FormattedMessage} from 'react-intl';
+
+export default class PermalinkView extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.getStateFromStores = this.getStateFromStores.bind(this);
+ this.isStateValid = this.isStateValid.bind(this);
+ this.updateState = this.updateState.bind(this);
+
+ this.state = this.getStateFromStores(props);
+ }
+ getStateFromStores(props) {
+ const postId = props.params.postid;
+ const channel = ChannelStore.getCurrent();
+ const channelId = channel ? channel.id : '';
+ const channelName = channel ? channel.name : '';
+ const team = TeamStore.getCurrent();
+ const teamName = team ? team.name : '';
+ return {
+ channelId,
+ channelName,
+ teamName,
+ postId
+ };
+ }
+ isStateValid() {
+ return this.state.channelId !== '' && this.state.teamName;
+ }
+ updateState() {
+ this.setState(this.getStateFromStores(this.props));
+ }
+ componentDidMount() {
+ ChannelStore.addChangeListener(this.updateState);
+ TeamStore.addChangeListener(this.updateState);
+ }
+ componentWillUnmount() {
+ ChannelStore.removeChangeListener(this.updateState);
+ TeamStore.removeChangeListener(this.updateState);
+ }
+ componentWillReceiveProps(nextProps) {
+ this.setState(this.getStateFromStores(nextProps));
+ }
+ render() {
+ if (!this.isStateValid()) {
+ return null;
+ }
+ return (
+ <div
+ id='app-content'
+ className='app__content'
+ >
+ <ChannelHeader
+ channelId={this.state.channelId}
+ />
+ <PostFocusView profiles={this.props.profiles}/>
+ <div
+ id='archive-link-home'
+ >
+ <Link
+ to={'/' + this.state.teamName + '/channels/' + this.state.channelName}
+ >
+ <FormattedMessage
+ id='center_panel.recent'
+ defaultMessage='Click here to jump to recent messages. '
+ />
+ <i className='fa fa-arrow-down'></i>
+ </Link>
+ </div>
+ </div>
+ );
+ }
+}
+
+PermalinkView.defaultProps = {
+};
+
+PermalinkView.propTypes = {
+ params: React.PropTypes.object.isRequired,
+ profiles: React.PropTypes.object
+};
diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx
index 819c7f590..226a1889c 100644
--- a/webapp/components/popover_list_members.jsx
+++ b/webapp/components/popover_list_members.jsx
@@ -6,10 +6,9 @@ import $ from 'jquery';
import UserStore from 'stores/user_store.jsx';
import {Popover, Overlay} from 'react-bootstrap';
import * as Utils from 'utils/utils.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import Constants from 'utils/constants.jsx';
-import ChannelStore from 'stores/channel_store.jsx';
-
import {FormattedMessage} from 'react-intl';
import React from 'react';
@@ -36,7 +35,7 @@ export default class PopoverListMembers extends React.Component {
Utils.openDirectChannelToUser(
teammate,
(channel, channelAlreadyExisted) => {
- Utils.switchChannel(channel);
+ GlobalActions.emitChannelClickEvent(channel);
if (channelAlreadyExisted) {
this.closePopover();
}
@@ -56,7 +55,6 @@ export default class PopoverListMembers extends React.Component {
const members = this.props.members;
const teamMembers = UserStore.getProfilesUsernameMap();
const currentUserId = UserStore.getCurrentId();
- const ch = ChannelStore.getCurrent();
if (members && teamMembers) {
members.sort((a, b) => {
@@ -68,7 +66,7 @@ export default class PopoverListMembers extends React.Component {
members.forEach((m, i) => {
let button = '';
- if (currentUserId !== m.id && ch.type !== 'D') {
+ if (currentUserId !== m.id && this.props.channel.type !== 'D') {
button = (
<a
href='#'
@@ -176,7 +174,7 @@ export default class PopoverListMembers extends React.Component {
}
PopoverListMembers.propTypes = {
+ channel: React.PropTypes.object.isRequired,
members: React.PropTypes.array.isRequired,
- memberCount: React.PropTypes.number,
- channelId: React.PropTypes.string.isRequired
+ memberCount: React.PropTypes.number
};
diff --git a/webapp/components/post.jsx b/webapp/components/post.jsx
index f2818188a..bbf8d9bf6 100644
--- a/webapp/components/post.jsx
+++ b/webapp/components/post.jsx
@@ -129,6 +129,7 @@ export default class Post extends React.Component {
const post = this.props.post;
const parentPost = this.props.parentPost;
const posts = this.props.posts;
+ const mattermostLogo = Constants.MATTERMOST_ICON_SVG;
if (!post.props) {
post.props = {};
@@ -188,9 +189,9 @@ export default class Post extends React.Component {
if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') {
if (post.props.override_icon_url) {
src = post.props.override_icon_url;
+ } else {
+ src = Constants.DEFAULT_WEBHOOK_LOGO;
}
- } else if (Utils.isSystemMessage(post)) {
- src = Constants.SYSTEM_MESSAGE_PROFILE_IMAGE;
}
profilePic = (
@@ -200,6 +201,15 @@ export default class Post extends React.Component {
width='36'
/>
);
+
+ if (Utils.isSystemMessage(post)) {
+ profilePic = (
+ <span
+ className='icon'
+ dangerouslySetInnerHTML={{__html: mattermostLogo}}
+ />
+ );
+ }
}
return (
diff --git a/webapp/components/post_body_additional_content.jsx b/webapp/components/post_body_additional_content.jsx
index 2cd82f213..452597dde 100644
--- a/webapp/components/post_body_additional_content.jsx
+++ b/webapp/components/post_body_additional_content.jsx
@@ -70,7 +70,7 @@ export default class PostBodyAdditionalContent extends React.Component {
return this.getSlackAttachment();
}
- const link = Utils.extractLinks(this.props.post.message)[0];
+ const link = Utils.extractFirstLink(this.props.post.message);
if (!link) {
return null;
}
diff --git a/webapp/components/post_info.jsx b/webapp/components/post_info.jsx
index 0aa71edd7..2d41b0e54 100644
--- a/webapp/components/post_info.jsx
+++ b/webapp/components/post_info.jsx
@@ -22,7 +22,7 @@ export default class PostInfo extends React.Component {
}
dropdownPosition(e) {
var position = $('#post-list').height() - $(e.target).offset().top;
- var dropdown = $(e.target).next('.dropdown-menu');
+ var dropdown = $(e.target).closest('.col__reply').find('.dropdown-menu');
if (position < dropdown.height()) {
dropdown.addClass('bottom');
}
diff --git a/webapp/components/posts_view.jsx b/webapp/components/posts_view.jsx
index 8b4b0c662..ffe04daa1 100644
--- a/webapp/components/posts_view.jsx
+++ b/webapp/components/posts_view.jsx
@@ -173,24 +173,15 @@ export default class PostsView extends React.Component {
const postFromWebhook = Boolean(post.props && post.props.from_webhook);
const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook);
const prevPostUserId = Utils.isSystemMessage(prevPost) ? '' : prevPost.user_id;
- let prevWebhookName = '';
- if (prevPost.props && prevPost.props.override_username) {
- prevWebhookName = prevPost.props.override_username;
- }
- let curWebhookName = '';
- if (post.props && post.props.override_username) {
- curWebhookName = post.props.override_username;
- }
// consider posts from the same user if:
// the previous post was made by the same user as the current post,
// the previous post was made within 5 minutes of the current post,
- // the previous post and current post are both from webhooks or both not,
- // the previous post and current post have the same webhook usernames
+ // the current post is not from a webhook
+ // the previous post is not from a webhook
if (prevPostUserId === postUserId &&
post.create_at - prevPost.create_at <= 1000 * 60 * 5 &&
- postFromWebhook === prevPostFromWebhook &&
- prevWebhookName === curWebhookName) {
+ !postFromWebhook && !prevPostFromWebhook) {
sameUser = true;
}
@@ -213,13 +204,11 @@ export default class PostsView extends React.Component {
// the previous post was made by the same user as the current post,
// the previous post is not a comment,
// the current post is not a comment,
- // the previous post and current post are both from webhooks or both not,
- // the previous post and current post have the same webhook usernames
+ // the current post is not from a webhook
if (prevPostUserId === postUserId &&
!prevPostIsComment &&
!postIsComment &&
- postFromWebhook === prevPostFromWebhook &&
- prevWebhookName === curWebhookName) {
+ !postFromWebhook) {
hideProfilePic = true;
}
}
@@ -319,7 +308,7 @@ export default class PostsView extends React.Component {
if (this.props.scrollType === PostsView.SCROLL_TYPE_BOTTOM) {
this.scrollToBottom();
} else if (this.props.scrollType === PostsView.SCROLL_TYPE_NEW_MESSAGE) {
- window.requestAnimationFrame(() => {
+ window.setTimeout(window.requestAnimationFrame(() => {
// If separator exists scroll to it. Otherwise scroll to bottom.
if (this.refs.newMessageSeparator) {
var objDiv = this.refs.postlist;
@@ -327,7 +316,7 @@ export default class PostsView extends React.Component {
} else if (this.refs.postlist) {
this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight;
}
- });
+ }), 0);
} else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPostId) {
window.requestAnimationFrame(() => {
const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPostId]);
@@ -384,6 +373,8 @@ export default class PostsView extends React.Component {
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
+ this.scrollStopAction.cancel();
+ PreferenceStore.removeChangeListener(this.updateState);
}
componentDidUpdate() {
if (this.props.postList != null) {
@@ -570,7 +561,9 @@ function FloatingTimestamp({isScrolling, post}) {
return (
<div className={className}>
- <span>{dateString}</span>
+ <div>
+ <span>{dateString}</span>
+ </div>
</div>
);
}
diff --git a/webapp/components/posts_view_container.jsx b/webapp/components/posts_view_container.jsx
index 7e334d4b0..a49c77f8d 100644
--- a/webapp/components/posts_view_container.jsx
+++ b/webapp/components/posts_view_container.jsx
@@ -8,7 +8,6 @@ import ChannelStore from 'stores/channel_store.jsx';
import PostStore from 'stores/post_store.jsx';
import UserStore from 'stores/user_store.jsx';
-import * as Utils from 'utils/utils.jsx';
import * as GlobalActions from 'action_creators/global_actions.jsx';
import Constants from 'utils/constants.jsx';
@@ -158,17 +157,6 @@ export default class PostsViewContainer extends React.Component {
this.setState({scrollType: PostsView.SCROLL_TYPE_FREE});
}
}
- shouldComponentUpdate(nextProps, nextState) {
- if (!Utils.areObjectsEqual(this.state, nextState)) {
- return true;
- }
-
- if (!Utils.areObjectsEqual(this.props, nextProps)) {
- return true;
- }
-
- return false;
- }
render() {
const postLists = this.state.postLists;
const channels = this.state.channels;
diff --git a/webapp/components/removed_from_channel_modal.jsx b/webapp/components/removed_from_channel_modal.jsx
index cdd51bd6e..45018ac99 100644
--- a/webapp/components/removed_from_channel_modal.jsx
+++ b/webapp/components/removed_from_channel_modal.jsx
@@ -6,7 +6,7 @@ import ReactDOM from 'react-dom';
import ChannelStore from 'stores/channel_store.jsx';
import UserStore from 'stores/user_store.jsx';
import BrowserStore from 'stores/browser_store.jsx';
-import * as utils from 'utils/utils.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import {FormattedMessage} from 'react-intl';
@@ -33,7 +33,7 @@ export default class RemovedFromChannelModal extends React.Component {
}
var townSquare = ChannelStore.getByName('town-square');
- setTimeout(() => utils.switchChannel(townSquare), 1);
+ setTimeout(() => GlobalActions.emitChannelClickEvent(townSquare), 1);
this.setState(newState);
}
diff --git a/webapp/components/rename_channel_modal.jsx b/webapp/components/rename_channel_modal.jsx
index 72828984c..ced3c2d2b 100644
--- a/webapp/components/rename_channel_modal.jsx
+++ b/webapp/components/rename_channel_modal.jsx
@@ -4,7 +4,7 @@
import ReactDOM from 'react-dom';
import * as Utils from 'utils/utils.jsx';
import * as Client from 'utils/client.jsx';
-import * as AsyncClient from 'utils/async_client.jsx';
+import * as GlobalActions from 'action_creators/global_actions.jsx';
import Constants from 'utils/constants.jsx';
import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
@@ -165,8 +165,7 @@ export default class RenameChannelModal extends React.Component {
Client.updateChannel(
channel,
() => {
- AsyncClient.getChannel(channel.id);
- Utils.updateAddressBar(channel.name);
+ GlobalActions.emitChannelClickEvent(channel);
this.handleHide();
},
diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx
index 26b392aa1..7a7c5f692 100644
--- a/webapp/components/rhs_root_post.jsx
+++ b/webapp/components/rhs_root_post.jsx
@@ -217,6 +217,8 @@ export default class RhsRootPost extends React.Component {
if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') {
if (post.props.override_icon_url) {
src = post.props.override_icon_url;
+ } else {
+ src = Constants.DEFAULT_WEBHOOK_LOGO;
}
} else if (Utils.isSystemMessage(post)) {
src = Constants.SYSTEM_MESSAGE_PROFILE_IMAGE;
diff --git a/webapp/components/root.jsx b/webapp/components/root.jsx
index 9963bc5dd..3b85b23fd 100644
--- a/webapp/components/root.jsx
+++ b/webapp/components/root.jsx
@@ -69,7 +69,7 @@ export default class Root extends React.Component {
FastClick.attach(document.body);
// Get our localizaiton
- GlobalActions.newLocalizationSelected('en');
+ GlobalActions.loadBrowserLocale();
}
componentWillUnmount() {
LocalizationStore.removeChangeListener(this.localizationChanged);
diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx
index 35769d06b..75cbcb2a0 100644
--- a/webapp/components/search_results_item.jsx
+++ b/webapp/components/search_results_item.jsx
@@ -1,7 +1,6 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import $ from 'jquery';
import UserStore from 'stores/user_store.jsx';
import UserProfile from './user_profile.jsx';
import * as GlobalActions from 'action_creators/global_actions.jsx';
@@ -10,28 +9,16 @@ import * as TextFormatting from 'utils/text_formatting.jsx';
import Constants from 'utils/constants.jsx';
import {FormattedMessage, FormattedDate} from 'react-intl';
-
import React from 'react';
+import {Link} from 'react-router';
export default class SearchResultsItem extends React.Component {
constructor(props) {
super(props);
- this.handleClick = this.handleClick.bind(this);
this.handleFocusRHSClick = this.handleFocusRHSClick.bind(this);
}
- handleClick(e) {
- e.preventDefault();
-
- GlobalActions.emitPostFocusEvent(this.props.post.id);
-
- if ($(window).width() < 768) {
- $('.sidebar--right').removeClass('move--left');
- $('.inner-wrap').removeClass('move--left');
- }
- }
-
handleFocusRHSClick(e) {
e.preventDefault();
GlobalActions.emitPostFocusRightHandSideFromSearch(this.props.post, this.props.isMentionSearch);
@@ -99,16 +86,15 @@ export default class SearchResultsItem extends React.Component {
</time>
</li>
<li>
- <a
- href='#'
+ <Link
+ to={'/' + window.location.pathname.split('/')[1] + '/pl/' + this.props.post.id}
className='search-item__jump'
- onClick={this.handleClick}
>
<FormattedMessage
id='search_item.jump'
defaultMessage='Jump'
/>
- </a>
+ </Link>
</li>
<li>
<a
diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx
index c0d4755ed..500e73cf2 100644
--- a/webapp/components/sidebar.jsx
+++ b/webapp/components/sidebar.jsx
@@ -15,7 +15,6 @@ import TeamStore from 'stores/team_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
-import * as Client from 'utils/client.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
@@ -29,7 +28,7 @@ import {Tooltip, OverlayTrigger} from 'react-bootstrap';
import loadingGif from 'images/load.gif';
import React from 'react';
-import {browserHistory} from 'react-router';
+import {browserHistory, Link} from 'react-router';
import favicon from 'images/favicon/favicon-16x16.png';
import redFavicon from 'images/favicon/redfavicon-16x16.png';
@@ -139,7 +138,9 @@ export default class Sidebar extends React.Component {
unreadCounts: JSON.parse(JSON.stringify(ChannelStore.getUnreadCounts())),
showTutorialTip: tutorialStep === TutorialSteps.CHANNEL_POPOVER,
currentTeam: TeamStore.getCurrent(),
- currentUser: UserStore.getCurrentUser()
+ currentUser: UserStore.getCurrentUser(),
+ townSquare: ChannelStore.getByName(Constants.DEFAULT_CHANNEL),
+ offTopic: ChannelStore.getByName(Constants.OFFTOPIC_CHANNEL)
};
}
@@ -239,7 +240,9 @@ export default class Sidebar extends React.Component {
});
}
- handleLeaveDirectChannel(channel) {
+ handleLeaveDirectChannel(e, channel) {
+ e.preventDefault();
+
if (!this.isLeaving.get(channel.id)) {
this.isLeaving.set(channel.id, true);
@@ -259,7 +262,7 @@ export default class Sidebar extends React.Component {
}
if (channel.id === this.state.activeId) {
- Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL));
+ browserHistory.push('/' + this.state.currentTeam.name + '/channels/town-square');
}
}
@@ -289,6 +292,16 @@ export default class Sidebar extends React.Component {
createTutorialTip() {
const screens = [];
+ let townSquareDisplayName = Constants.DEFAULT_CHANNEL_UI_NAME;
+ if (this.state.townSquare) {
+ townSquareDisplayName = this.state.townSquare.display_name;
+ }
+
+ let offTopicDisplayName = Constants.OFFTOPIC_CHANNEL_UI_NAME;
+ if (this.state.offTopic) {
+ offTopicDisplayName = this.state.offTopic.display_name;
+ }
+
screens.push(
<div>
<FormattedHTMLMessage
@@ -302,10 +315,14 @@ export default class Sidebar extends React.Component {
<div>
<FormattedHTMLMessage
id='sidebar.tutorialScreen2'
- defaultMessage='<h4>"Town Square" and "Off-Topic" channels</h4>
+ defaultMessage='<h4>"{townsquare}" and "{offtopic}" channels</h4>
<p>Here are two public channels to start:</p>
- <p><strong>Town Square</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p>
- <p><strong>Off-Topic</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>'
+ <p><strong>{townsquare}</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p>
+ <p><strong>{offtopic}</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>'
+ values={{
+ townsquare: townSquareDisplayName,
+ offtopic: offTopicDisplayName
+ }}
/>
</div>
);
@@ -406,48 +423,6 @@ export default class Sidebar extends React.Component {
icon = <div className='status'><i className='fa fa-lock'></i></div>;
}
- // set up click handler to switch channels (or create a new channel for non-existant ones)
- var handleClick = null;
-
- if (!channel.fake) {
- handleClick = function clickHandler(e) {
- if (e.target.attributes.getNamedItem('data-close')) {
- handleClose(channel);
- } else {
- Utils.switchChannel(channel);
- }
-
- e.preventDefault();
- };
- } else if (channel.fake) {
- // It's a direct message channel that doesn't exist yet so let's create it now
- var otherUserId = Utils.getUserIdFromChannelName(channel);
-
- if (this.state.loadingDMChannel === -1) {
- handleClick = function clickHandler(e) {
- e.preventDefault();
-
- if (e.target.attributes.getNamedItem('data-close')) {
- handleClose(channel);
- } else {
- this.setState({loadingDMChannel: index});
-
- Client.createDirectChannel(channel, otherUserId,
- (data) => {
- this.setState({loadingDMChannel: -1});
- AsyncClient.getChannel(data.id);
- Utils.switchChannel(data);
- },
- () => {
- this.setState({loadingDMChannel: -1});
- browserHistory('/' + this.state.currentTeam.name);
- }
- );
- }
- }.bind(this);
- }
- }
-
let closeButton = null;
const removeTooltip = (
<Tooltip id='remove-dm-tooltip'>
@@ -464,12 +439,12 @@ export default class Sidebar extends React.Component {
placement='top'
overlay={removeTooltip}
>
- <span
- className='btn-close'
- data-close='true'
- >
- {'Γ—'}
- </span>
+ <span
+ onClick={(e) => handleClose(e, channel)}
+ className='btn-close'
+ >
+ {'Γ—'}
+ </span>
</OverlayTrigger>
);
@@ -481,23 +456,29 @@ export default class Sidebar extends React.Component {
tutorialTip = this.createTutorialTip();
}
+ let link = '';
+ if (channel.fake) {
+ link = '/' + this.state.currentTeam.name + '/channels/' + channel.name + '?fakechannel=' + encodeURIComponent(JSON.stringify(channel));
+ } else {
+ link = '/' + this.state.currentTeam.name + '/channels/' + channel.name;
+ }
+
return (
<li
key={channel.name}
ref={channel.name}
className={linkClass}
>
- <a
+ <Link
+ to={link}
className={rowClass}
- href={'#'}
- onClick={handleClick}
>
{icon}
{status}
{channel.display_name}
{badge}
{closeButton}
- </a>
+ </Link>
{tutorialTip}
</li>
);
@@ -600,6 +581,7 @@ export default class Sidebar extends React.Component {
<div
className='sidebar--left'
id='sidebar-left'
+ key='sidebar-left'
>
<NewChannelFlow
show={showChannelModal}
diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right.jsx
index a2e3914f3..594674929 100644
--- a/webapp/components/sidebar_right.jsx
+++ b/webapp/components/sidebar_right.jsx
@@ -29,7 +29,7 @@ export default class SidebarRight extends React.Component {
this.doStrangeThings = this.doStrangeThings.bind(this);
this.state = {
- searchVisible: !!SearchStore.getSearchResults(),
+ searchVisible: SearchStore.getSearchResults() !== null,
isMentionSearch: SearchStore.getIsMentionSearch(),
postRightVisible: !!PostStore.getSelectedPost(),
fromSearch: false,
@@ -111,7 +111,7 @@ export default class SidebarRight extends React.Component {
}
onSearchChange() {
this.setState({
- searchVisible: !!SearchStore.getSearchResults(),
+ searchVisible: SearchStore.getSearchResults() !== null,
isMentionSearch: SearchStore.getIsMentionSearch()
});
}
diff --git a/webapp/components/signup_team.jsx b/webapp/components/signup_team.jsx
index e6b27e745..3ad47500d 100644
--- a/webapp/components/signup_team.jsx
+++ b/webapp/components/signup_team.jsx
@@ -138,6 +138,24 @@ export default class TeamSignUp extends React.Component {
}
let signupMethod = null;
+ let goBack = (
+ <div className='signup-header'>
+ <a
+ href='#'
+ onClick={
+ (e) => {
+ e.preventDefault();
+ this.updatePage('choose');
+ }
+ }
+ >
+ <span className='fa fa-chevron-left'/>
+ <FormattedMessage
+ id='web.header.back'
+ />
+ </a>
+ </div>
+ );
if (global.window.mm_config.EnableTeamCreation !== 'true') {
if (teamListing == null) {
@@ -154,9 +172,12 @@ export default class TeamSignUp extends React.Component {
updatePage={this.updatePage}
/>
);
+ goBack = null;
} else if (this.state.page === 'email') {
signupMethod = (
- <EmailSignUpPage/>
+ <div>
+ <EmailSignUpPage/>
+ </div>
);
} else if (this.state.page === 'ldap') {
return (
@@ -180,24 +201,28 @@ export default class TeamSignUp extends React.Component {
defaultMessage='No team creation method has been enabled. Please contact an administrator for access.'
/>
);
+ goBack = null;
}
return (
- <div className='col-sm-12'>
- <div className='signup-team__container'>
- <img
- className='signup-team-logo'
- src={logoImage}
- />
- <h1>{global.window.mm_config.SiteName}</h1>
- <h4 className='color--light'>
- <FormattedMessage
- id='web.root.singup_info'
+ <div>
+ {goBack}
+ <div className='col-sm-12'>
+ <div className='signup-team__container'>
+ <img
+ className='signup-team-logo'
+ src={logoImage}
/>
- </h4>
- <div id='signup-team'>
- {teamListing}
- {signupMethod}
+ <h1>{global.window.mm_config.SiteName}</h1>
+ <h4 className='color--light'>
+ <FormattedMessage
+ id='web.root.singup_info'
+ />
+ </h4>
+ <div id='signup-team'>
+ {teamListing}
+ {signupMethod}
+ </div>
</div>
</div>
</div>
diff --git a/webapp/components/signup_team_complete/components/signup_team_complete.jsx b/webapp/components/signup_team_complete/components/signup_team_complete.jsx
index 8b2096499..e5d310151 100644
--- a/webapp/components/signup_team_complete/components/signup_team_complete.jsx
+++ b/webapp/components/signup_team_complete/components/signup_team_complete.jsx
@@ -8,7 +8,7 @@ import {FormattedMessage} from 'react-intl';
import {browserHistory} from 'react-router';
import React from 'react';
-import Link from 'react-router';
+import {Link} from 'react-router';
export default class SignupTeamComplete extends React.Component {
constructor(props) {
@@ -55,7 +55,7 @@ export default class SignupTeamComplete extends React.Component {
<div>
<div className='signup-header'>
<Link to='/'>
- <span classNameName='fa fa-chevron-left'/>
+ <span className='fa fa-chevron-left'/>
<FormattedMessage id='web.header.back'/>
</Link>
</div>
diff --git a/webapp/components/signup_user_complete.jsx b/webapp/components/signup_user_complete.jsx
index 5460daf29..e9f9d9d88 100644
--- a/webapp/components/signup_user_complete.jsx
+++ b/webapp/components/signup_user_complete.jsx
@@ -1,18 +1,21 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import ReactDOM from 'react-dom';
+import LoadingScreen from 'components/loading_screen.jsx';
+import LoginLdap from 'components/login/components/login_ldap.jsx';
+
+import BrowserStore from 'stores/browser_store.jsx';
+import UserStore from 'stores/user_store.jsx';
+
import * as Utils from 'utils/utils.jsx';
import * as Client from 'utils/client.jsx';
-import UserStore from 'stores/user_store.jsx';
-import BrowserStore from 'stores/browser_store.jsx';
import Constants from 'utils/constants.jsx';
-import LoadingScreen from 'components/loading_screen.jsx';
import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
import {browserHistory, Link} from 'react-router';
import React from 'react';
+import ReactDOM from 'react-dom';
import logoImage from 'images/logo.png';
@@ -314,13 +317,13 @@ class SignupUserComplete extends React.Component {
</div>
);
- var signupMessage = [];
+ let signupMessage = [];
if (global.window.mm_config.EnableSignUpWithGitLab === 'true') {
signupMessage.push(
- <Link
+ <a
className='btn btn-custom-login gitlab'
key='gitlab'
- to={'/api/v1/oauth/gitlab/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)}
+ href={'/api/v1/oauth/gitlab/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)}
>
<span className='icon'/>
<span>
@@ -329,16 +332,16 @@ class SignupUserComplete extends React.Component {
defaultMessage='with GitLab'
/>
</span>
- </Link>
+ </a>
);
}
if (global.window.mm_config.EnableSignUpWithGoogle === 'true') {
signupMessage.push(
- <Link
+ <a
className='btn btn-custom-login google'
key='google'
- to={'/api/v1/oauth/google/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)}
+ href={'/api/v1/oauth/google/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)}
>
<span className='icon'/>
<span>
@@ -347,11 +350,26 @@ class SignupUserComplete extends React.Component {
defaultMessage='with Google'
/>
</span>
- </Link>
+ </a>
);
}
- var emailSignup;
+ let ldapSignup;
+ if (global.window.mm_config.EnableLdap === 'true' && global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.LDAP) {
+ ldapSignup = (
+ <div className='inner__content'>
+ <h5><strong>
+ <FormattedMessage
+ id='signup_user_completed.withLdap'
+ defaultMessage='With your LDAP credentials'
+ />
+ </strong></h5>
+ <LoginLdap teamName={this.state.teamName}/>
+ </div>
+ );
+ }
+
+ let emailSignup;
if (global.window.mm_config.EnableSignUpWithEmail === 'true') {
emailSignup = (
<div>
@@ -397,24 +415,24 @@ class SignupUserComplete extends React.Component {
{passwordError}
</div>
</div>
+ <p className='margin--extra'>
+ <button
+ type='submit'
+ onClick={this.handleSubmit}
+ className='btn-primary btn'
+ >
+ <FormattedMessage
+ id='signup_user_completed.create'
+ defaultMessage='Create Account'
+ />
+ </button>
+ </p>
</div>
- <p className='margin--extra'>
- <button
- type='submit'
- onClick={this.handleSubmit}
- className='btn-primary btn'
- >
- <FormattedMessage
- id='signup_user_completed.create'
- defaultMessage='Create Account'
- />
- </button>
- </p>
</div>
);
}
- if (signupMessage.length > 0 && emailSignup) {
+ if (signupMessage.length > 0 && (emailSignup || ldapSignup)) {
signupMessage = (
<div>
{signupMessage}
@@ -428,7 +446,21 @@ class SignupUserComplete extends React.Component {
);
}
- if (signupMessage.length === 0 && !emailSignup) {
+ if (ldapSignup && emailSignup) {
+ ldapSignup = (
+ <div>
+ {ldapSignup}
+ <div className='or__container'>
+ <FormattedMessage
+ id='signup_user_completed.or'
+ defaultMessage='or'
+ />
+ </div>
+ </div>
+ );
+ }
+
+ if (signupMessage.length === 0 && !emailSignup && !ldapSignup) {
emailSignup = (
<div>
<FormattedMessage
@@ -449,7 +481,7 @@ class SignupUserComplete extends React.Component {
</div>
<div className='col-sm-12'>
<div className='signup-team__container padding--less'>
- <form>
+ <div>
<img
className='signup-team-logo'
src={logoImage}
@@ -477,9 +509,10 @@ class SignupUserComplete extends React.Component {
/>
</h4>
{signupMessage}
+ {ldapSignup}
{emailSignup}
{serverError}
- </form>
+ </div>
</div>
</div>
</div>
diff --git a/webapp/components/spinner_button.jsx b/webapp/components/spinner_button.jsx
index fcc9af8cd..becf395c5 100644
--- a/webapp/components/spinner_button.jsx
+++ b/webapp/components/spinner_button.jsx
@@ -14,20 +14,10 @@ export default class SpinnerButton extends React.Component {
};
}
- constructor(props) {
- super(props);
-
- this.handleClick = this.handleClick.bind(this);
- }
-
- handleClick(e) {
- if (this.props.onClick) {
- this.props.onClick(e);
- }
- }
-
render() {
- if (this.props.spinning) {
+ const {spinning, children, ...props} = this.props; // eslint-disable-line no-use-before-define
+
+ if (spinning) {
return (
<img
className='spinner-button__gif'
@@ -38,10 +28,10 @@ export default class SpinnerButton extends React.Component {
return (
<button
- onClick={this.handleClick}
- className='btn btn-sm btn-primary'
+ className='btn btn-primary'
+ {...props}
>
- {this.props.children}
+ {children}
</button>
);
}
diff --git a/webapp/components/suggestion/search_suggestion_list.jsx b/webapp/components/suggestion/search_suggestion_list.jsx
index b15cc4243..57aaee8ff 100644
--- a/webapp/components/suggestion/search_suggestion_list.jsx
+++ b/webapp/components/suggestion/search_suggestion_list.jsx
@@ -2,6 +2,7 @@
// See License.txt for license information.
import $ from 'jquery';
+import React from 'react';
import ReactDOM from 'react-dom';
import Constants from 'utils/constants.jsx';
import SuggestionList from './suggestion_list.jsx';
diff --git a/webapp/components/team_general_tab.jsx b/webapp/components/team_general_tab.jsx
index 776c84b48..c27e8ca59 100644
--- a/webapp/components/team_general_tab.jsx
+++ b/webapp/components/team_general_tab.jsx
@@ -84,6 +84,7 @@ class GeneralTab extends React.Component {
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
this.setState(this.setupInitialState(this.props));
this.props.updateSection(section);
}
diff --git a/webapp/components/team_settings_modal.jsx b/webapp/components/team_settings_modal.jsx
index c19787993..657643367 100644
--- a/webapp/components/team_settings_modal.jsx
+++ b/webapp/components/team_settings_modal.jsx
@@ -62,6 +62,7 @@ class TeamSettingsModal extends React.Component {
}
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
this.setState({activeSection: section});
}
render() {
diff --git a/webapp/components/team_signup_with_sso.jsx b/webapp/components/team_signup_with_sso.jsx
index 9a46b2d6b..78396eea8 100644
--- a/webapp/components/team_signup_with_sso.jsx
+++ b/webapp/components/team_signup_with_sso.jsx
@@ -64,7 +64,7 @@ class SSOSignUpPage extends React.Component {
this.props.service,
(data) => {
if (data.follow_link) {
- browserHistory.push(data.follow_link);
+ window.location.href = data.follow_link;
} else {
browserHistory.push('/' + team.name + '/channels/town-square');
}
diff --git a/webapp/components/textbox.jsx b/webapp/components/textbox.jsx
index 371c581e5..c77e1f9a3 100644
--- a/webapp/components/textbox.jsx
+++ b/webapp/components/textbox.jsx
@@ -130,8 +130,8 @@ export default class Textbox extends React.Component {
const helpText = (
<div
- style={{visibility: hasText ? 'visible' : 'hidden', opacity: hasText ? '0.5' : '0'}}
- className='help_format_text'
+ style={{visibility: hasText ? 'visible' : 'hidden', opacity: hasText ? '0.3' : '0'}}
+ className='help__format-text'
>
<b>
<FormattedMessage
@@ -208,8 +208,8 @@ export default class Textbox extends React.Component {
dangerouslySetInnerHTML={{__html: this.state.preview ? TextFormatting.formatText(this.props.messageText) : ''}}
>
</div>
- {helpText}
<div className='help__text'>
+ {helpText}
{previewLink}
<a
target='_blank'
diff --git a/webapp/components/tutorial/tutorial_intro_screens.jsx b/webapp/components/tutorial/tutorial_intro_screens.jsx
index 913a30483..0358a6a65 100644
--- a/webapp/components/tutorial/tutorial_intro_screens.jsx
+++ b/webapp/components/tutorial/tutorial_intro_screens.jsx
@@ -2,7 +2,6 @@
// See License.txt for license information.
import UserStore from 'stores/user_store.jsx';
-import ChannelStore from 'stores/channel_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
import * as Utils from 'utils/utils.jsx';
@@ -11,6 +10,7 @@ import * as AsyncClient from 'utils/async_client.jsx';
import Constants from 'utils/constants.jsx';
import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
+import {browserHistory} from 'react-router';
const Preferences = Constants.Preferences;
@@ -19,6 +19,12 @@ const NUM_SCREENS = 3;
import React from 'react';
export default class TutorialIntroScreens extends React.Component {
+ static get propTypes() {
+ return {
+ townSquare: React.PropTypes.object,
+ offTopic: React.PropTypes.object
+ };
+ }
constructor(props) {
super(props);
@@ -34,7 +40,7 @@ export default class TutorialIntroScreens extends React.Component {
return;
}
- Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL));
+ browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/town-square');
const step = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 0);
@@ -52,6 +58,8 @@ export default class TutorialIntroScreens extends React.Component {
UserStore.getCurrentId(),
'999'
);
+
+ browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/town-square');
}
createScreen() {
switch (this.state.currentScreen) {
@@ -151,6 +159,11 @@ export default class TutorialIntroScreens extends React.Component {
);
}
+ let townSquareDisplayName = Constants.DEFAULT_CHANNEL_UI_NAME;
+ if (this.props.townSquare) {
+ townSquareDisplayName = this.props.townSquare.display_name;
+ }
+
return (
<div>
<h3>
@@ -169,7 +182,10 @@ export default class TutorialIntroScreens extends React.Component {
{supportInfo}
<FormattedMessage
id='tutorial_intro.end'
- defaultMessage='Click β€œNext” to enter Town Square. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.'
+ defaultMessage='Click β€œNext” to enter {channel}. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.'
+ values={{
+ channel: townSquareDisplayName
+ }}
/>
{circles}
</div>
diff --git a/webapp/components/tutorial/tutorial_tip.jsx b/webapp/components/tutorial/tutorial_tip.jsx
index 3508e29a2..deca70794 100644
--- a/webapp/components/tutorial/tutorial_tip.jsx
+++ b/webapp/components/tutorial/tutorial_tip.jsx
@@ -15,6 +15,9 @@ import {Overlay} from 'react-bootstrap';
import React from 'react';
+import tutorialGif from 'images/tutorialTip.gif';
+import tutorialGifWhite from 'images/tutorialTipWhite.gif';
+
export default class TutorialTip extends React.Component {
constructor(props) {
super(props);
@@ -90,16 +93,16 @@ export default class TutorialTip extends React.Component {
}
}
- var tipColor = '';
+ var tutorialGifImage = tutorialGif;
if (this.props.overlayClass === 'tip-overlay--header' || this.props.overlayClass === 'tip-overlay--sidebar') {
- tipColor = 'White';
+ tutorialGifImage = tutorialGifWhite;
}
return (
<div className={'tip-div ' + this.props.overlayClass}>
<img
className='tip-button'
- src={'/static/images/tutorialTip' + tipColor + '.gif'}
+ src={tutorialGifImage}
width='35'
onClick={this.toggle}
ref='target'
diff --git a/webapp/components/tutorial/tutorial_view.jsx b/webapp/components/tutorial/tutorial_view.jsx
new file mode 100644
index 000000000..5f2c1a257
--- /dev/null
+++ b/webapp/components/tutorial/tutorial_view.jsx
@@ -0,0 +1,44 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import TutorialIntroScreens from './tutorial_intro_screens.jsx';
+
+import ChannelStore from 'stores/channel_store.jsx';
+import Constants from 'utils/constants.jsx';
+
+import React from 'react';
+
+export default class TutorialView extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleChannelChange = this.handleChannelChange.bind(this);
+
+ this.state = {
+ townSquare: ChannelStore.getByName(Constants.DEFAULT_CHANNEL)
+ };
+ }
+ componentDidMount() {
+ ChannelStore.addChangeListener(this.handleChannelChange);
+ }
+ componentWillUnmount() {
+ ChannelStore.removeChangeListener(this.handleChannelChange);
+ }
+ handleChannelChange() {
+ this.setState({
+ townSquare: ChannelStore.getByName(Constants.DEFAULT_CHANNEL)
+ });
+ }
+ render() {
+ return (
+ <div
+ id='app-content'
+ className='app__content'
+ >
+ <TutorialIntroScreens
+ townSquare={this.state.townSquare}
+ />
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/user_settings/manage_incoming_hooks.jsx b/webapp/components/user_settings/manage_incoming_hooks.jsx
deleted file mode 100644
index b61b331ce..000000000
--- a/webapp/components/user_settings/manage_incoming_hooks.jsx
+++ /dev/null
@@ -1,225 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import * as Client from 'utils/client.jsx';
-import * as Utils from 'utils/utils.jsx';
-import Constants from 'utils/constants.jsx';
-import ChannelStore from 'stores/channel_store.jsx';
-import LoadingScreen from '../loading_screen.jsx';
-
-import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
-
-import React from 'react';
-
-export default class ManageIncomingHooks extends React.Component {
- constructor() {
- super();
-
- this.getHooks = this.getHooks.bind(this);
- this.addNewHook = this.addNewHook.bind(this);
- this.updateChannelId = this.updateChannelId.bind(this);
-
- this.state = {hooks: [], channelId: ChannelStore.getByName(Constants.DEFAULT_CHANNEL).id, getHooksComplete: false};
- }
- componentDidMount() {
- this.getHooks();
- }
- addNewHook() {
- const hook = {};
- hook.channel_id = this.state.channelId;
-
- Client.addIncomingHook(
- hook,
- (data) => {
- let hooks = this.state.hooks;
- if (!hooks) {
- hooks = [];
- }
- hooks.push(data);
- this.setState({hooks});
- },
- (err) => {
- this.setState({serverError: err});
- }
- );
- }
- removeHook(id) {
- const data = {};
- data.id = id;
-
- Client.deleteIncomingHook(
- data,
- () => {
- const hooks = this.state.hooks;
- let index = -1;
- for (let i = 0; i < hooks.length; i++) {
- if (hooks[i].id === id) {
- index = i;
- break;
- }
- }
-
- if (index !== -1) {
- hooks.splice(index, 1);
- }
-
- this.setState({hooks});
- },
- (err) => {
- this.setState({serverError: err});
- }
- );
- }
- getHooks() {
- Client.listIncomingHooks(
- (data) => {
- const state = this.state;
-
- if (data) {
- state.hooks = data;
- }
-
- state.getHooksComplete = true;
- this.setState(state);
- },
- (err) => {
- this.setState({serverError: err});
- }
- );
- }
- updateChannelId(e) {
- this.setState({channelId: e.target.value});
- }
- render() {
- let serverError;
- if (this.state.serverError) {
- serverError = <label className='has-error'>{this.state.serverError}</label>;
- }
-
- const channels = ChannelStore.getAll();
- const options = [];
- channels.forEach((channel) => {
- if (channel.type !== Constants.DM_CHANNEL) {
- options.push(
- <option
- key={'incoming-hook' + channel.id}
- value={channel.id}
- >
- {channel.display_name}
- </option>
- );
- }
- });
-
- let disableButton = '';
- if (this.state.channelId === '') {
- disableButton = ' disable';
- }
-
- const hooks = [];
- this.state.hooks.forEach((hook) => {
- const c = ChannelStore.get(hook.channel_id);
- if (c) {
- hooks.push(
- <div
- key={hook.id}
- className='webhook__item'
- >
- <div className='padding-top x2 webhook__url'>
- <strong>{'URL: '}</strong>
- <span className='word-break--all'>{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id}</span>
- </div>
- <div className='padding-top'>
- <strong>
- <FormattedMessage
- id='user.settings.hooks_in.channel'
- defaultMessage='Channel: '
- />
- </strong>{c.display_name}
- </div>
- <a
- className={'webhook__remove'}
- href='#'
- onClick={this.removeHook.bind(this, hook.id)}
- >
- <span aria-hidden='true'>{'Γ—'}</span>
- </a>
- <div className='padding-top x2 divider-light'></div>
- </div>
- );
- }
- });
-
- let displayHooks;
- if (!this.state.getHooksComplete) {
- displayHooks = <LoadingScreen/>;
- } else if (hooks.length > 0) {
- displayHooks = hooks;
- } else {
- displayHooks = (
- <div className='padding-top x2'>
- <FormattedMessage
- id='user.settings.hooks_in.none'
- defaultMessage='None'
- />
- </div>
- );
- }
-
- const existingHooks = (
- <div className='webhooks__container'>
- <label className='control-label padding-top x2'>
- <FormattedMessage
- id='user.settings.hooks_in.existing'
- defaultMessage='Existing incoming webhooks'
- />
- </label>
- <div className='padding-top divider-light'></div>
- <div className='webhooks__list'>
- {displayHooks}
- </div>
- </div>
- );
-
- return (
- <div key='addIncomingHook'>
- <FormattedHTMLMessage
- id='user.settings.hooks_in.description'
- defaultMessage='Create webhook URLs for use in external integrations. Please see <a href="http://docs.mattermost.com/developer/webhooks-incoming.html" target="_blank">incoming webhooks documentation</a> to learn more. View all incoming webhooks configured on this team below.'
- />
- <div><label className='control-label padding-top x2'>
- <FormattedMessage
- id='user.settings.hooks_in.addTitle'
- defaultMessage='Add a new incoming webhook'
- />
- </label></div>
- <div className='row padding-top'>
- <div className='col-sm-10 padding-bottom'>
- <select
- ref='channelName'
- className='form-control'
- value={this.state.channelId}
- onChange={this.updateChannelId}
- >
- {options}
- </select>
- {serverError}
- </div>
- <div className='col-sm-2 col-xs-4 no-padding--left padding-bottom'>
- <a
- className={'btn form-control no-padding btn-sm btn-primary' + disableButton}
- href='#'
- onClick={this.addNewHook}
- >
- <FormattedMessage
- id='user.settings.hooks_in.add'
- defaultMessage='Add'
- />
- </a>
- </div>
- </div>
- {existingHooks}
- </div>
- );
- }
-}
diff --git a/webapp/components/user_settings/manage_outgoing_hooks.jsx b/webapp/components/user_settings/manage_outgoing_hooks.jsx
deleted file mode 100644
index 8adec09ce..000000000
--- a/webapp/components/user_settings/manage_outgoing_hooks.jsx
+++ /dev/null
@@ -1,397 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import LoadingScreen from '../loading_screen.jsx';
-
-import ChannelStore from 'stores/channel_store.jsx';
-
-import * as Client from 'utils/client.jsx';
-import Constants from 'utils/constants.jsx';
-
-import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl';
-
-const holders = defineMessages({
- optional: {
- id: 'user.settings.hooks_out.optional',
- defaultMessage: 'Optional if channel selected'
- },
- callbackHolder: {
- id: 'user.settings.hooks_out.callbackHolder',
- defaultMessage: 'Each URL must start with http:// or https://'
- },
- select: {
- id: 'user.settings.hooks_out.select',
- defaultMessage: '--- Select a channel ---'
- }
-});
-
-import React from 'react';
-
-class ManageOutgoingHooks extends React.Component {
- constructor() {
- super();
-
- this.getHooks = this.getHooks.bind(this);
- this.addNewHook = this.addNewHook.bind(this);
- this.updateChannelId = this.updateChannelId.bind(this);
- this.updateTriggerWords = this.updateTriggerWords.bind(this);
- this.updateCallbackURLs = this.updateCallbackURLs.bind(this);
-
- this.state = {hooks: [], channelId: '', triggerWords: '', callbackURLs: '', getHooksComplete: false};
- }
- componentDidMount() {
- this.getHooks();
- }
- addNewHook(e) {
- e.preventDefault();
-
- if ((this.state.channelId === '' && this.state.triggerWords === '') ||
- this.state.callbackURLs === '') {
- return;
- }
-
- const hook = {};
- hook.channel_id = this.state.channelId;
- if (this.state.triggerWords.length !== 0) {
- hook.trigger_words = this.state.triggerWords.trim().split(',');
- }
- hook.callback_urls = this.state.callbackURLs.split('\n').map((url) => url.trim());
-
- Client.addOutgoingHook(
- hook,
- (data) => {
- let hooks = Object.assign([], this.state.hooks);
- if (!hooks) {
- hooks = [];
- }
- hooks.push(data);
- this.setState({hooks, addError: null, channelId: '', triggerWords: '', callbackURLs: ''});
- },
- (err) => {
- this.setState({addError: err.message});
- }
- );
- }
- removeHook(id) {
- const data = {};
- data.id = id;
-
- Client.deleteOutgoingHook(
- data,
- () => {
- const hooks = this.state.hooks;
- let index = -1;
- for (let i = 0; i < hooks.length; i++) {
- if (hooks[i].id === id) {
- index = i;
- break;
- }
- }
-
- if (index !== -1) {
- hooks.splice(index, 1);
- }
-
- this.setState({hooks});
- },
- (err) => {
- this.setState({editError: err.message});
- }
- );
- }
- regenToken(id) {
- const regenData = {};
- regenData.id = id;
-
- Client.regenOutgoingHookToken(
- regenData,
- (data) => {
- const hooks = Object.assign([], this.state.hooks);
- for (let i = 0; i < hooks.length; i++) {
- if (hooks[i].id === id) {
- hooks[i] = data;
- break;
- }
- }
-
- this.setState({hooks, editError: null});
- },
- (err) => {
- this.setState({editError: err.message});
- }
- );
- }
- getHooks() {
- Client.listOutgoingHooks(
- (data) => {
- if (data) {
- this.setState({hooks: data, getHooksComplete: true, editError: null});
- }
- },
- (err) => {
- this.setState({editError: err.message});
- }
- );
- }
- updateChannelId(e) {
- this.setState({channelId: e.target.value});
- }
- updateTriggerWords(e) {
- this.setState({triggerWords: e.target.value});
- }
- updateCallbackURLs(e) {
- this.setState({callbackURLs: e.target.value});
- }
- render() {
- let addError;
- if (this.state.addError) {
- addError = <label className='has-error'>{this.state.addError}</label>;
- }
- let editError;
- if (this.state.editError) {
- addError = <label className='has-error'>{this.state.editError}</label>;
- }
-
- const channels = ChannelStore.getAll();
- const options = [];
- options.push(
- <option
- key='select-channel'
- value=''
- >
- {this.props.intl.formatMessage(holders.select)}
- </option>
- );
-
- channels.forEach((channel) => {
- if (channel.type === Constants.OPEN_CHANNEL) {
- options.push(
- <option
- key={'outgoing-hook' + channel.id}
- value={channel.id}
- >
- {channel.display_name}
- </option>
- );
- }
- });
-
- const hooks = [];
- this.state.hooks.forEach((hook) => {
- const c = ChannelStore.get(hook.channel_id);
-
- if (!c && hook.channel_id && hook.channel_id.length !== 0) {
- return;
- }
-
- let channelDiv;
- if (c) {
- channelDiv = (
- <div className='padding-top'>
- <strong>
- <FormattedMessage
- id='user.settings.hooks_out.channel'
- defaultMessage='Channel: '
- />
- </strong>{c.display_name}
- </div>
- );
- }
-
- let triggerDiv;
- if (hook.trigger_words && hook.trigger_words.length !== 0) {
- triggerDiv = (
- <div className='padding-top'>
- <strong>
- <FormattedMessage
- id='user.settings.hooks_out.trigger'
- defaultMessage='Trigger Words: '
- />
- </strong>{hook.trigger_words.join(', ')}
- </div>
- );
- }
-
- hooks.push(
- <div
- key={hook.id}
- className='webhook__item'
- >
- <div className='padding-top x2 webhook__url'>
- <strong>{'URLs: '}</strong><span className='word-break--all'>{hook.callback_urls.join(', ')}</span>
- </div>
- {channelDiv}
- {triggerDiv}
- <div className='padding-top'>
- <strong>{'Token: '}</strong>{hook.token}
- </div>
- <div className='padding-top'>
- <a
- className='text-danger'
- href='#'
- onClick={this.regenToken.bind(this, hook.id)}
- >
- <FormattedMessage
- id='user.settings.hooks_out.regen'
- defaultMessage='Regen Token'
- />
- </a>
- <a
- className='webhook__remove'
- href='#'
- onClick={this.removeHook.bind(this, hook.id)}
- >
- <span aria-hidden='true'>{'Γ—'}</span>
- </a>
- </div>
- <div className='padding-top x2 divider-light'></div>
- </div>
- );
- });
-
- let displayHooks;
- if (!this.state.getHooksComplete) {
- displayHooks = <LoadingScreen/>;
- } else if (hooks.length > 0) {
- displayHooks = hooks;
- } else {
- displayHooks = (
- <div className='padding-top x2'>
- <FormattedMessage
- id='user.settings.hooks_out.none'
- defaultMessage='None'
- />
- </div>
- );
- }
-
- const existingHooks = (
- <div className='webhooks__container'>
- <label className='control-label padding-top x2'>
- <FormattedMessage
- id='user.settings.hooks_out.existing'
- defaultMessage='Existing outgoing webhooks'
- />
- </label>
- <div className='padding-top divider-light'></div>
- <div className='webhooks__list'>
- {displayHooks}
- </div>
- </div>
- );
-
- const disableButton = (this.state.channelId === '' && this.state.triggerWords === '') || this.state.callbackURLs === '';
-
- return (
- <div key='addOutgoingHook'>
- <FormattedHTMLMessage
- id='user.settings.hooks_out.addDescription'
- defaultMessage='Create webhooks to send new message events to an external integration. Please see <a href="http://docs.mattermost.com/developer/webhooks-outgoing.html" target="_blank">outgoing webhooks documentation</a> to learn more. View all outgoing webhooks configured on this team below.'
- />
- <div><label className='control-label padding-top x2'>
- <FormattedMessage
- id='user.settings.hooks_out.addTitle'
- defaultMessage='Add a new outgoing webhook'
- />
- </label></div>
- <div className='padding-top divider-light'></div>
- <div className='padding-top'>
- <div>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.hooks_out.channel'
- defaultMessage='Channel: '
- />
- </label>
- <div className='padding-top'>
- <select
- ref='channelName'
- className='form-control'
- value={this.state.channelId}
- onChange={this.updateChannelId}
- >
- {options}
- </select>
- </div>
- <div className='padding-top'>
- <FormattedMessage
- id='user.settings.hooks_out.only'
- defaultMessage='Only public channels can be used'
- />
- </div>
- </div>
- <div className='padding-top x2'>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.hooks_out.trigger'
- defaultMessage='Trigger Words: '
- />
- </label>
- <div className='padding-top'>
- <input
- ref='triggerWords'
- className='form-control'
- value={this.state.triggerWords}
- onChange={this.updateTriggerWords}
- placeholder={this.props.intl.formatMessage(holders.optional)}
- />
- </div>
- <div className='padding-top'>
- <FormattedMessage
- id='user.settings.hooks_out.comma'
- defaultMessage='Comma separated words to trigger on'
- />
- </div>
- </div>
- <div className='padding-top x2'>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.hooks_out.callback'
- defaultMessage='Callback URLs: '
- />
- </label>
- <div className='padding-top'>
- <textarea
- ref='callbackURLs'
- className='form-control no-resize'
- value={this.state.callbackURLs}
- resize={false}
- rows={3}
- onChange={this.updateCallbackURLs}
- placeholder={this.props.intl.formatMessage(holders.callbackHolder)}
- />
- </div>
- <div className='padding-top'>
- <FormattedMessage
- id='user.settings.hooks_out.callbackDesc'
- defaultMessage='New line separated URLs that will receive the HTTP POST event'
- />
- </div>
- {addError}
- </div>
- <div className='padding-top padding-bottom'>
- <a
- className={'btn btn-sm btn-primary'}
- href='#'
- disabled={disableButton}
- onClick={this.addNewHook}
- >
- <FormattedMessage
- id='user.settings.hooks_out.add'
- defaultMessage='Add'
- />
- </a>
- </div>
- </div>
- {existingHooks}
- {editError}
- </div>
- );
- }
-}
-
-ManageOutgoingHooks.propTypes = {
- intl: intlShape.isRequired
-};
-
-export default injectIntl(ManageOutgoingHooks);
diff --git a/webapp/components/user_settings/premade_theme_chooser.jsx b/webapp/components/user_settings/premade_theme_chooser.jsx
index c35748b41..326120957 100644
--- a/webapp/components/user_settings/premade_theme_chooser.jsx
+++ b/webapp/components/user_settings/premade_theme_chooser.jsx
@@ -7,6 +7,8 @@ import Constants from 'utils/constants.jsx';
import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
export default class PremadeThemeChooser extends React.Component {
constructor(props) {
super(props);
@@ -50,6 +52,17 @@ export default class PremadeThemeChooser extends React.Component {
return (
<div className='row appearance-section'>
{premadeThemes}
+ <div className='col-sm-12 padding-bottom x2'>
+ <a
+ href='http://docs.mattermost.com/help/settings/theme-colors.html#custom-themes'
+ target='_blank'
+ >
+ <FormattedMessage
+ id='user.settings.display.theme.otherThemes'
+ defaultMessage='See other themes'
+ />
+ </a>
+ </div>
</div>
);
}
diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx
index 4fcdc9a41..61e0e1dad 100644
--- a/webapp/components/user_settings/user_settings_advanced.jsx
+++ b/webapp/components/user_settings/user_settings_advanced.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import * as AsyncClient from 'utils/async_client.jsx';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
@@ -151,6 +152,7 @@ class AdvancedSettingsDisplay extends React.Component {
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
this.props.updateSection(section);
}
diff --git a/webapp/components/user_settings/user_settings_display.jsx b/webapp/components/user_settings/user_settings_display.jsx
index e56156049..d169e01b5 100644
--- a/webapp/components/user_settings/user_settings_display.jsx
+++ b/webapp/components/user_settings/user_settings_display.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
import ManageLanguages from './manage_languages.jsx';
@@ -83,6 +84,7 @@ export default class UserSettingsDisplay extends React.Component {
this.setState({selectedFont});
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
this.updateState();
this.props.updateSection(section);
}
@@ -302,7 +304,7 @@ export default class UserSettingsDisplay extends React.Component {
describe = (
<FormattedMessage
id='user.settings.display.showUsername'
- defaultMessage='Show username (team default)'
+ defaultMessage='Show username (default)'
/>
);
} else if (this.state.nameFormat === 'full_name') {
diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx
index 2129847aa..eddbc1efe 100644
--- a/webapp/components/user_settings/user_settings_general.jsx
+++ b/webapp/components/user_settings/user_settings_general.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
import SettingPicture from '../setting_picture.jsx';
@@ -13,7 +14,7 @@ import Constants from 'utils/constants.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedDate} from 'react-intl';
+import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage, FormattedDate} from 'react-intl';
const holders = defineMessages({
usernameReserved: {
@@ -36,18 +37,6 @@ const holders = defineMessages({
id: 'user.settings.general.checkEmail',
defaultMessage: 'Check your email at {email} to verify the address.'
},
- newAddress: {
- id: 'user.settings.general.newAddress',
- defaultMessage: 'New Address: {email}<br />Check your email to verify the above address.'
- },
- checkEmailNoAddress: {
- id: 'user.settings.general.checkEmailNoAddress',
- defaultMessage: 'Check your email to verify your new address'
- },
- loginGitlab: {
- id: 'user.settings.general.loginGitlab',
- defaultMessage: 'Log in done through GitLab'
- },
validImage: {
id: 'user.settings.general.validImage',
defaultMessage: 'Only JPG or PNG images may be used for profile pictures'
@@ -72,10 +61,6 @@ const holders = defineMessages({
id: 'user.settings.general.username',
defaultMessage: 'Username'
},
- email: {
- id: 'user.settings.general.email',
- defaultMessage: 'Email'
- },
profilePicture: {
id: 'user.settings.general.profilePicture',
defaultMessage: 'Profile Picture'
@@ -286,6 +271,7 @@ class UserSettingsGeneralTab extends React.Component {
}
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
const emailChangeInProgress = this.state.emailChangeInProgress;
this.setState(Object.assign({}, this.setupInitialState(this.props), {emailChangeInProgress, clientError: '', serverError: '', emailError: ''}));
this.submitActive = false;
@@ -297,9 +283,224 @@ class UserSettingsGeneralTab extends React.Component {
return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname,
email: user.email, confirmEmail: '', picture: null, loadingPicture: false, emailChangeInProgress: false};
}
+ createEmailSection() {
+ let emailSection;
+
+ if (this.props.activeSection === 'email') {
+ const emailEnabled = global.window.mm_config.SendEmailNotifications === 'true';
+ const emailVerificationEnabled = global.window.mm_config.RequireEmailVerification === 'true';
+ const inputs = [];
+
+ let helpText = (
+ <FormattedMessage
+ id='user.settings.general.emailHelp1'
+ defaultMessage='Email is used for sign-in, notifications, and password reset. Email requires verification if changed.'
+ />
+ );
+
+ if (!emailEnabled) {
+ helpText = (
+ <div className='setting-list__hint text-danger'>
+ <FormattedMessage
+ id='user.settings.general.emailHelp2'
+ defaultMessage='Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.'
+ />
+ </div>
+ );
+ } else if (!emailVerificationEnabled) {
+ helpText = (
+ <FormattedMessage
+ id='user.settings.general.emailHelp3'
+ defaultMessage='Email is used for sign-in, notifications, and password reset.'
+ />
+ );
+ } else if (this.state.emailChangeInProgress) {
+ const newEmail = UserStore.getCurrentUser().email;
+ if (newEmail) {
+ helpText = (
+ <FormattedMessage
+ id='user.settings.general.emailHelp4'
+ defaultMessage='A verification email was sent to {email}.'
+ values={{
+ email: newEmail
+ }}
+ />
+ );
+ }
+ }
+
+ let submit = null;
+
+ if (this.props.user.auth_service === '') {
+ inputs.push(
+ <div key='emailSetting'>
+ <div className='form-group'>
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.general.primaryEmail'
+ defaultMessage='Primary Email'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='text'
+ onChange={this.updateEmail}
+ value={this.state.email}
+ />
+ </div>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div key='confirmEmailSetting'>
+ <div className='form-group'>
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.general.confirmEmail'
+ defaultMessage='Confirm Email'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='text'
+ onChange={this.updateConfirmEmail}
+ value={this.state.confirmEmail}
+ />
+ </div>
+ </div>
+ {helpText}
+ </div>
+ );
+
+ submit = this.submitEmail;
+ } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
+ inputs.push(
+ <div
+ key='oauthEmailInfo'
+ className='form-group'
+ >
+ <div className='setting-list__hint'>
+ <FormattedMessage
+ id='user.settings.general.emailGitlabCantUpdate'
+ defaultMessage='Login occurs through GitLab. Email cannot be updated. Email address used for notifications is {email}.'
+ values={{
+ email: this.state.email
+ }}
+ />
+ </div>
+ {helpText}
+ </div>
+ );
+ } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) {
+ inputs.push(
+ <div
+ key='oauthEmailInfo'
+ className='form-group'
+ >
+ <div className='setting-list__hint'>
+ <FormattedMessage
+ id='user.settings.general.emailLdapCantUpdate'
+ defaultMessage='Login occurs through LDAP. Email cannot be updated. Email address used for notifications is {email}.'
+ values={{
+ email: this.state.email
+ }}
+ />
+ </div>
+ {helpText}
+ </div>
+ );
+ }
+
+ emailSection = (
+ <SettingItemMax
+ title={
+ <FormattedMessage
+ id='user.settings.general.email'
+ defaultMessage='Email'
+ />
+ }
+ inputs={inputs}
+ submit={submit}
+ server_error={this.state.serverError}
+ client_error={this.state.emailError}
+ updateSection={(e) => {
+ this.updateSection('');
+ e.preventDefault();
+ }}
+ />
+ );
+ } else {
+ let describe = '';
+ if (this.props.user.auth_service === '') {
+ if (this.state.emailChangeInProgress) {
+ const newEmail = UserStore.getCurrentUser().email;
+ if (newEmail) {
+ describe = (
+ <FormattedHTMLMessage
+ id='user.settings.general.newAddress'
+ defaultMessage='New Address: {email}<br />Check your email to verify the above address.'
+ values={{
+ email: newEmail
+ }}
+ />
+ );
+ } else {
+ describe = (
+ <FormattedMessage
+ id='user.settings.general.checkEmailNoAddress'
+ defaultMessage='Check your email to verify your new address'
+ />
+ );
+ }
+ } else {
+ describe = UserStore.getCurrentUser().email;
+ }
+ } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
+ describe = (
+ <FormattedMessage
+ id='user.settings.general.loginGitlab'
+ defaultMessage='Login done through GitLab ({email})'
+ values={{
+ email: this.state.email
+ }}
+ />
+ );
+ } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) {
+ describe = (
+ <FormattedMessage
+ id='user.settings.general.loginLdap'
+ defaultMessage='Login done through LDAP ({email})'
+ values={{
+ email: this.state.email
+ }}
+ />
+ );
+ }
+
+ emailSection = (
+ <SettingItemMin
+ title={
+ <FormattedMessage
+ id='user.settings.general.email'
+ defaultMessage='Email'
+ />
+ }
+ describe={describe}
+ updateSection={() => {
+ this.updateSection('email');
+ }}
+ />
+ );
+ }
+
+ return emailSection;
+ }
render() {
const user = this.props.user;
- const {formatMessage, formatHTMLMessage} = this.props.intl;
+ const {formatMessage} = this.props.intl;
let clientError = null;
if (this.state.clientError) {
@@ -309,10 +510,6 @@ class UserSettingsGeneralTab extends React.Component {
if (this.state.serverError) {
serverError = this.state.serverError;
}
- let emailError = null;
- if (this.state.emailError) {
- emailError = this.state.emailError;
- }
let nameSection;
const inputs = [];
@@ -407,20 +604,27 @@ class UserSettingsGeneralTab extends React.Component {
/>
);
} else {
- let fullName = '';
+ let describe = '';
if (user.first_name && user.last_name) {
- fullName = user.first_name + ' ' + user.last_name;
+ describe = user.first_name + ' ' + user.last_name;
} else if (user.first_name) {
- fullName = user.first_name;
+ describe = user.first_name;
} else if (user.last_name) {
- fullName = user.last_name;
+ describe = user.last_name;
+ } else {
+ describe = (
+ <FormattedMessage
+ id='user.settings.general.emptyName'
+ defaultMessage="Click 'Edit' to add your full name"
+ />
+ );
}
nameSection = (
<SettingItemMin
title={formatMessage(holders.fullName)}
- describe={fullName}
+ describe={describe}
updateSection={() => {
this.updateSection('name');
}}
@@ -481,10 +685,22 @@ class UserSettingsGeneralTab extends React.Component {
/>
);
} else {
+ let describe = '';
+ if (user.nickname) {
+ describe = user.nickname;
+ } else {
+ describe = (
+ <FormattedMessage
+ id='user.settings.general.emptyNickname'
+ defaultMessage="Click 'Edit' to add a nickname"
+ />
+ );
+ }
+
nicknameSection = (
<SettingItemMin
title={formatMessage(holders.nickname)}
- describe={UserStore.getCurrentUser().nickname}
+ describe={describe}
updateSection={() => {
this.updateSection('nickname');
}}
@@ -557,152 +773,7 @@ class UserSettingsGeneralTab extends React.Component {
);
}
- let emailSection;
- if (this.props.activeSection === 'email') {
- const emailEnabled = global.window.mm_config.SendEmailNotifications === 'true';
- const emailVerificationEnabled = global.window.mm_config.RequireEmailVerification === 'true';
- let helpText = (
- <FormattedMessage
- id='user.settings.general.emailHelp1'
- defaultMessage='Email is used for sign-in, notifications, and password reset. Email requires verification if changed.'
- />
- );
-
- if (!emailEnabled) {
- helpText = (
- <div className='setting-list__hint text-danger'>
- <FormattedMessage
- id='user.settings.general.emailHelp2'
- defaultMessage='Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.'
- />
- </div>
- );
- } else if (!emailVerificationEnabled) {
- helpText = (
- <FormattedMessage
- id='user.settings.general.emailHelp3'
- defaultMessage='Email is used for sign-in, notifications, and password reset.'
- />
- );
- } else if (this.state.emailChangeInProgress) {
- const newEmail = UserStore.getCurrentUser().email;
- if (newEmail) {
- helpText = (
- <FormattedMessage
- id='user.settings.general.emailHelp4'
- defaultMessage='A verification email was sent to {email}.'
- values={{
- email: newEmail
- }}
- />
- );
- }
- }
-
- let submit = null;
-
- if (this.props.user.auth_service === '') {
- inputs.push(
- <div key='emailSetting'>
- <div className='form-group'>
- <label className='col-sm-5 control-label'>
- <FormattedMessage
- id='user.settings.general.primaryEmail'
- defaultMessage='Primary Email'
- />
- </label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='text'
- onChange={this.updateEmail}
- value={this.state.email}
- />
- </div>
- </div>
- </div>
- );
-
- inputs.push(
- <div key='confirmEmailSetting'>
- <div className='form-group'>
- <label className='col-sm-5 control-label'>
- <FormattedMessage
- id='user.settings.general.confirmEmail'
- defaultMessage='Confirm Email'
- />
- </label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='text'
- onChange={this.updateConfirmEmail}
- value={this.state.confirmEmail}
- />
- </div>
- </div>
- {helpText}
- </div>
- );
-
- submit = this.submitEmail;
- } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
- inputs.push(
- <div
- key='oauthEmailInfo'
- className='form-group'
- >
- <div className='setting-list__hint'>
- <FormattedMessage
- id='user.settings.general.emailCantUpdate'
- defaultMessage='Log in occurs through GitLab. Email cannot be updated.'
- />
- </div>
- {helpText}
- </div>
- );
- }
-
- emailSection = (
- <SettingItemMax
- title='Email'
- inputs={inputs}
- submit={submit}
- server_error={serverError}
- client_error={emailError}
- updateSection={(e) => {
- this.updateSection('');
- e.preventDefault();
- }}
- />
- );
- } else {
- let describe = '';
- if (this.props.user.auth_service === '') {
- if (this.state.emailChangeInProgress) {
- const newEmail = UserStore.getCurrentUser().email;
- if (newEmail) {
- describe = formatHTMLMessage(holders.newAddress, {email: newEmail});
- } else {
- describe = formatMessage(holders.checkEmailNoAddress);
- }
- } else {
- describe = UserStore.getCurrentUser().email;
- }
- } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
- describe = formatMessage(holders.loginGitlab);
- }
-
- emailSection = (
- <SettingItemMin
- title={formatMessage(holders.email)}
- describe={describe}
- updateSection={() => {
- this.updateSection('email');
- }}
- />
- );
- }
+ const emailSection = this.createEmailSection();
let pictureSection;
if (this.props.activeSection === 'picture') {
diff --git a/webapp/components/user_settings/user_settings_integrations.jsx b/webapp/components/user_settings/user_settings_integrations.jsx
index 94fc184bd..37081b863 100644
--- a/webapp/components/user_settings/user_settings_integrations.jsx
+++ b/webapp/components/user_settings/user_settings_integrations.jsx
@@ -1,31 +1,14 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
-import ManageIncomingHooks from './manage_incoming_hooks.jsx';
-import ManageOutgoingHooks from './manage_outgoing_hooks.jsx';
import ManageCommandHooks from './manage_command_hooks.jsx';
import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
const holders = defineMessages({
- inName: {
- id: 'user.settings.integrations.incomingWebhooks',
- defaultMessage: 'Incoming Webhooks'
- },
- inDesc: {
- id: 'user.settings.integrations.incomingWebhooksDescription',
- defaultMessage: 'Manage your incoming webhooks'
- },
- outName: {
- id: 'user.settings.integrations.outWebhooks',
- defaultMessage: 'Outgoing Webhooks'
- },
- outDesc: {
- id: 'user.settings.integrations.outWebhooksDescription',
- defaultMessage: 'Manage your outgoing webhooks'
- },
cmdName: {
id: 'user.settings.integrations.commands',
defaultMessage: 'Slash Commands'
@@ -47,77 +30,14 @@ class UserSettingsIntegrationsTab extends React.Component {
this.state = {};
}
updateSection(section) {
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
this.props.updateSection(section);
}
render() {
- let incomingHooksSection;
- let outgoingHooksSection;
let commandHooksSection;
var inputs = [];
const {formatMessage} = this.props.intl;
- if (global.window.mm_config.EnableIncomingWebhooks === 'true') {
- if (this.props.activeSection === 'incoming-hooks') {
- inputs.push(
- <ManageIncomingHooks key='incoming-hook-ui'/>
- );
-
- incomingHooksSection = (
- <SettingItemMax
- title={formatMessage(holders.inName)}
- width='medium'
- inputs={inputs}
- updateSection={(e) => {
- this.updateSection('');
- e.preventDefault();
- }}
- />
- );
- } else {
- incomingHooksSection = (
- <SettingItemMin
- title={formatMessage(holders.inName)}
- width='medium'
- describe={formatMessage(holders.inDesc)}
- updateSection={() => {
- this.updateSection('incoming-hooks');
- }}
- />
- );
- }
- }
-
- if (global.window.mm_config.EnableOutgoingWebhooks === 'true') {
- if (this.props.activeSection === 'outgoing-hooks') {
- inputs.push(
- <ManageOutgoingHooks key='outgoing-hook-ui'/>
- );
-
- outgoingHooksSection = (
- <SettingItemMax
- title={formatMessage(holders.outName)}
- width='medium'
- inputs={inputs}
- updateSection={(e) => {
- this.updateSection('');
- e.preventDefault();
- }}
- />
- );
- } else {
- outgoingHooksSection = (
- <SettingItemMin
- title={formatMessage(holders.outName)}
- width='medium'
- describe={formatMessage(holders.outDesc)}
- updateSection={() => {
- this.updateSection('outgoing-hooks');
- }}
- />
- );
- }
- }
-
if (global.window.mm_config.EnableCommands === 'true') {
if (this.props.activeSection === 'command-hooks') {
inputs.push(
@@ -185,10 +105,6 @@ class UserSettingsIntegrationsTab extends React.Component {
/>
</h3>
<div className='divider-dark first'/>
- {incomingHooksSection}
- <div className='divider-light'/>
- {outgoingHooksSection}
- <div className='divider-dark'/>
{commandHooksSection}
<div className='divider-dark'/>
</div>
@@ -207,4 +123,4 @@ UserSettingsIntegrationsTab.propTypes = {
collapseModal: React.PropTypes.func.isRequired
};
-export default injectIntl(UserSettingsIntegrationsTab); \ No newline at end of file
+export default injectIntl(UserSettingsIntegrationsTab);
diff --git a/webapp/components/user_settings/user_settings_modal.jsx b/webapp/components/user_settings/user_settings_modal.jsx
index d1c1f0fe2..b71547baf 100644
--- a/webapp/components/user_settings/user_settings_modal.jsx
+++ b/webapp/components/user_settings/user_settings_modal.jsx
@@ -9,7 +9,6 @@ import SettingsSidebar from '../settings_sidebar.jsx';
import UserStore from 'stores/user_store.jsx';
import * as Utils from 'utils/utils.jsx';
-import Constants from 'utils/constants.jsx';
import {Modal} from 'react-bootstrap';
@@ -113,7 +112,6 @@ class UserSettingsModal extends React.Component {
return;
}
- this.resetTheme();
this.deactivateTab();
this.props.onModalDismissed();
return;
@@ -220,22 +218,10 @@ class UserSettingsModal extends React.Component {
if (!skipConfirm && this.requireConfirm) {
this.showConfirmModal(() => this.updateSection(section, true));
} else {
- if (this.state.active_section === 'theme' && section !== 'theme') {
- this.resetTheme();
- }
this.setState({active_section: section});
}
}
- resetTheme() {
- const user = UserStore.getCurrentUser();
- if (user.theme_props == null) {
- Utils.applyTheme(Constants.THEMES.default);
- } else {
- Utils.applyTheme(user.theme_props);
- }
- }
-
render() {
const {formatMessage} = this.props.intl;
if (this.state.currentUser == null) {
diff --git a/webapp/components/user_settings/user_settings_notifications.jsx b/webapp/components/user_settings/user_settings_notifications.jsx
index fe2db6727..b119c42f9 100644
--- a/webapp/components/user_settings/user_settings_notifications.jsx
+++ b/webapp/components/user_settings/user_settings_notifications.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import ReactDOM from 'react-dom';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
@@ -162,6 +163,7 @@ class NotificationsTab extends React.Component {
this.updateState();
this.props.updateSection('');
e.preventDefault();
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
}
updateSection(section) {
this.updateState();
diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx
index 283d2c425..ff5a898a9 100644
--- a/webapp/components/user_settings/user_settings_security.jsx
+++ b/webapp/components/user_settings/user_settings_security.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import $ from 'jquery';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
import AccessHistoryModal from '../access_history_modal.jsx';
@@ -30,14 +31,6 @@ const holders = defineMessages({
id: 'user.settings.security.passwordMatchError',
defaultMessage: 'The new passwords you entered do not match'
},
- password: {
- id: 'user.settings.security.password',
- defaultMessage: 'Password'
- },
- lastUpdated: {
- id: 'user.settings.security.lastUpdated',
- defaultMessage: 'Last updated {date} at {time}'
- },
method: {
id: 'user.settings.security.method',
defaultMessage: 'Sign-in Method'
@@ -55,12 +48,16 @@ class SecurityTab extends React.Component {
super(props);
this.submitPassword = this.submitPassword.bind(this);
+ this.activateMfa = this.activateMfa.bind(this);
+ this.deactivateMfa = this.deactivateMfa.bind(this);
this.updateCurrentPassword = this.updateCurrentPassword.bind(this);
this.updateNewPassword = this.updateNewPassword.bind(this);
this.updateConfirmPassword = this.updateConfirmPassword.bind(this);
+ this.updateMfaToken = this.updateMfaToken.bind(this);
this.getDefaultState = this.getDefaultState.bind(this);
this.createPasswordSection = this.createPasswordSection.bind(this);
this.createSignInSection = this.createSignInSection.bind(this);
+ this.showQrCode = this.showQrCode.bind(this);
this.state = this.getDefaultState();
}
@@ -69,7 +66,9 @@ class SecurityTab extends React.Component {
currentPassword: '',
newPassword: '',
confirmPassword: '',
- authService: this.props.user.auth_service
+ authService: this.props.user.auth_service,
+ mfaShowQr: false,
+ mfaToken: ''
};
}
submitPassword(e) {
@@ -120,6 +119,51 @@ class SecurityTab extends React.Component {
}
);
}
+ activateMfa() {
+ const data = {};
+ data.activate = true;
+ data.token = this.state.mfaToken;
+
+ Client.updateMfa(data,
+ () => {
+ this.props.updateSection('');
+ AsyncClient.getMe();
+ this.setState(this.getDefaultState());
+ },
+ (err) => {
+ const state = this.getDefaultState();
+ if (err.message) {
+ state.serverError = err.message;
+ } else {
+ state.serverError = err;
+ }
+ state.mfaError = '';
+ this.setState(state);
+ }
+ );
+ }
+ deactivateMfa() {
+ const data = {};
+ data.activate = false;
+
+ Client.updateMfa(data,
+ () => {
+ this.props.updateSection('');
+ AsyncClient.getMe();
+ this.setState(this.getDefaultState());
+ },
+ (err) => {
+ const state = this.getDefaultState();
+ if (err.message) {
+ state.serverError = err.message;
+ } else {
+ state.serverError = err;
+ }
+ state.mfaError = '';
+ this.setState(state);
+ }
+ );
+ }
updateCurrentPassword(e) {
this.setState({currentPassword: e.target.value});
}
@@ -129,123 +173,335 @@ class SecurityTab extends React.Component {
updateConfirmPassword(e) {
this.setState({confirmPassword: e.target.value});
}
- createPasswordSection() {
+ updateMfaToken(e) {
+ this.setState({mfaToken: e.target.value});
+ }
+ showQrCode(e) {
+ e.preventDefault();
+ this.setState({mfaShowQr: true});
+ }
+ createMfaSection() {
let updateSectionStatus;
- const {formatMessage} = this.props.intl;
-
- if (this.props.activeSection === 'password' && this.props.user.auth_service === '') {
- const inputs = [];
+ let submit;
+
+ if (this.props.activeSection === 'mfa') {
+ let content;
+ let extraInfo;
+ if (this.props.user.mfa_active) {
+ content = (
+ <div key='mfaQrCode'>
+ <a
+ className='btn btn-primary'
+ href='#'
+ onClick={this.deactivateMfa}
+ >
+ <FormattedMessage
+ id='user.settings.mfa.remove'
+ defaultMessage='Remove MFA from your account'
+ />
+ </a>
+ <br/>
+ </div>
+ );
- inputs.push(
- <div
- key='currentPasswordUpdateForm'
- className='form-group'
- >
- <label className='col-sm-5 control-label'>
+ extraInfo = (
+ <span>
<FormattedMessage
- id='user.settings.security.currentPassword'
- defaultMessage='Current Password'
- />
- </label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='password'
- onChange={this.updateCurrentPassword}
- value={this.state.currentPassword}
+ id='user.settings.mfa.removeHelp'
+ defaultMessage='Removing multi-factor authentication will make your account more vulnerable to attacks.'
/>
+ </span>
+ );
+ } else if (this.state.mfaShowQr) {
+ content = (
+ <div key='mfaButton'>
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.mfa.qrCode'
+ defaultMessage='QR Code'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <img
+ className='qr-code-img'
+ src={'/api/v1/users/generate_mfa_qr?time=' + this.props.user.update_at}
+ />
+ </div>
+ <br/>
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.mfa.enterToken'
+ defaultMessage='Token'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='text'
+ onChange={this.updateMfaToken}
+ value={this.state.mfaToken}
+ />
+ </div>
</div>
- </div>
- );
- inputs.push(
- <div
- key='newPasswordUpdateForm'
- className='form-group'
- >
- <label className='col-sm-5 control-label'>
+ );
+
+ extraInfo = (
+ <span>
<FormattedMessage
- id='user.settings.security.newPassword'
- defaultMessage='New Password'
- />
- </label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='password'
- onChange={this.updateNewPassword}
- value={this.state.newPassword}
+ id='user.settings.mfa.addHelpQr'
+ defaultMessage='Please scan the QR code with the Google Authenticator app on your smartphone and fill in the token with one provided by the app.'
/>
+ </span>
+ );
+
+ submit = this.activateMfa;
+ } else {
+ content = (
+ <div key='mfaQrCode'>
+ <a
+ className='btn btn-primary'
+ href='#'
+ onClick={this.showQrCode}
+ >
+ <FormattedMessage
+ id='user.settings.mfa.add'
+ defaultMessage='Add MFA to your account'
+ />
+ </a>
+ <br/>
</div>
- </div>
- );
+ );
+
+ extraInfo = (
+ <span>
+ <FormattedMessage
+ id='user.settings.mfa.addHelp'
+ defaultMessage='To add multi-factor authentication to your account you must have a smartphone with Google Authenticator installed.'
+ />
+ </span>
+ );
+ }
+
+ const inputs = [];
inputs.push(
<div
- key='retypeNewPasswordUpdateForm'
+ key='mfaSetting'
className='form-group'
>
- <label className='col-sm-5 control-label'>
- <FormattedMessage
- id='user.settings.security.retypePassword'
- defaultMessage='Retype New Password'
- />
- </label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='password'
- onChange={this.updateConfirmPassword}
- value={this.state.confirmPassword}
- />
- </div>
+ {content}
</div>
);
updateSectionStatus = function resetSection(e) {
this.props.updateSection('');
- this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null});
+ this.setState({mfaToken: '', mfaShowQr: false, mfaError: null});
e.preventDefault();
}.bind(this);
return (
<SettingItemMax
- title={formatMessage(holders.password)}
+ title={Utils.localizeMessage('user.settings.mfa.title', 'Multi-factor Authentication')}
inputs={inputs}
- submit={this.submitPassword}
+ extraInfo={extraInfo}
+ submit={submit}
server_error={this.state.serverError}
- client_error={this.state.passwordError}
+ client_error={this.state.mfaError}
updateSection={updateSectionStatus}
/>
);
}
- var describe;
- var d = new Date(this.props.user.last_password_update);
+ let describe;
+ if (this.props.user.mfa_active) {
+ describe = Utils.localizeMessage('user.settings.security.active', 'Active');
+ } else {
+ describe = Utils.localizeMessage('user.settings.security.inactive', 'Inactive');
+ }
+
+ updateSectionStatus = function updateSection() {
+ this.props.updateSection('mfa');
+ }.bind(this);
- const hours12 = !Utils.isMilitaryTime();
- describe = (
- <FormattedMessage
- id='user.settings.security.lastUpdated'
- defaultMessage='Last updated {date} at {time}'
- values={{
- date: (
- <FormattedDate
- value={d}
- day='2-digit'
- month='short'
- year='numeric'
- />
- ),
- time: (
- <FormattedTime
- value={d}
- hour12={hours12}
- hour='2-digit'
- minute='2-digit'
- />
- )
- }}
+ return (
+ <SettingItemMin
+ title={Utils.localizeMessage('user.settings.mfa.title', 'Multi-factor Authentication')}
+ describe={describe}
+ updateSection={updateSectionStatus}
/>
);
+ }
+ createPasswordSection() {
+ let updateSectionStatus;
+
+ if (this.props.activeSection === 'password') {
+ const inputs = [];
+ let submit;
+
+ if (this.props.user.auth_service === '') {
+ submit = this.submitPassword;
+
+ inputs.push(
+ <div
+ key='currentPasswordUpdateForm'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.security.currentPassword'
+ defaultMessage='Current Password'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='password'
+ onChange={this.updateCurrentPassword}
+ value={this.state.currentPassword}
+ />
+ </div>
+ </div>
+ );
+ inputs.push(
+ <div
+ key='newPasswordUpdateForm'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.security.newPassword'
+ defaultMessage='New Password'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='password'
+ onChange={this.updateNewPassword}
+ value={this.state.newPassword}
+ />
+ </div>
+ </div>
+ );
+ inputs.push(
+ <div
+ key='retypeNewPasswordUpdateForm'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>
+ <FormattedMessage
+ id='user.settings.security.retypePassword'
+ defaultMessage='Retype New Password'
+ />
+ </label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='password'
+ onChange={this.updateConfirmPassword}
+ value={this.state.confirmPassword}
+ />
+ </div>
+ </div>
+ );
+ } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
+ inputs.push(
+ <div
+ key='oauthEmailInfo'
+ className='form-group'
+ >
+ <div className='setting-list__hint'>
+ <FormattedMessage
+ id='user.settings.security.passwordGitlabCantUpdate'
+ defaultMessage='Login occurs through GitLab. Password cannot be updated.'
+ />
+ </div>
+ </div>
+ );
+ } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) {
+ inputs.push(
+ <div
+ key='oauthEmailInfo'
+ className='form-group'
+ >
+ <div className='setting-list__hint'>
+ <FormattedMessage
+ id='user.settings.security.passwordLdapCantUpdate'
+ defaultMessage='Login occurs through LDAP. Password cannot be updated.'
+ />
+ </div>
+ </div>
+ );
+ }
+
+ updateSectionStatus = function resetSection(e) {
+ this.props.updateSection('');
+ this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null});
+ e.preventDefault();
+ $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
+ }.bind(this);
+
+ return (
+ <SettingItemMax
+ title={
+ <FormattedMessage
+ id='user.settings.security.password'
+ defaultMessage='Password'
+ />
+ }
+ inputs={inputs}
+ submit={submit}
+ server_error={this.state.serverError}
+ client_error={this.state.passwordError}
+ updateSection={updateSectionStatus}
+ />
+ );
+ }
+
+ let describe;
+
+ if (this.props.user.auth_service === '') {
+ const d = new Date(this.props.user.last_password_update);
+ const hours12 = !Utils.isMilitaryTime();
+
+ describe = (
+ <FormattedMessage
+ id='user.settings.security.lastUpdated'
+ defaultMessage='Last updated {date} at {time}'
+ values={{
+ date: (
+ <FormattedDate
+ value={d}
+ day='2-digit'
+ month='short'
+ year='numeric'
+ />
+ ),
+ time: (
+ <FormattedTime
+ value={d}
+ hour12={hours12}
+ hour='2-digit'
+ minute='2-digit'
+ />
+ )
+ }}
+ />
+ );
+ } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
+ describe = (
+ <FormattedMessage
+ id='user.settings.security.loginGitlab'
+ defaultMessage='Login done through Gitlab'
+ />
+ );
+ } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) {
+ describe = (
+ <FormattedMessage
+ id='user.settings.security.loginLdap'
+ defaultMessage='Login done through LDAP'
+ />
+ );
+ }
updateSectionStatus = function updateSection() {
this.props.updateSection('password');
@@ -253,7 +509,12 @@ class SecurityTab extends React.Component {
return (
<SettingItemMin
- title={formatMessage(holders.password)}
+ title={
+ <FormattedMessage
+ id='user.settings.security.password'
+ defaultMessage='Password'
+ />
+ }
describe={describe}
updateSection={updateSectionStatus}
/>
@@ -264,7 +525,6 @@ class SecurityTab extends React.Component {
const user = this.props.user;
if (this.props.activeSection === 'signin') {
- const inputs = [];
const teamName = TeamStore.getCurrent().name;
let emailOption;
@@ -346,6 +606,7 @@ class SecurityTab extends React.Component {
);
}
+ const inputs = [];
inputs.push(
<div key='userSignInOption'>
{emailOption}
@@ -411,16 +672,22 @@ class SecurityTab extends React.Component {
}
render() {
const passwordSection = this.createPasswordSection();
- let signInSection;
let numMethods = 0;
numMethods = global.window.mm_config.EnableSignUpWithGitLab === 'true' ? numMethods + 1 : numMethods;
numMethods = global.window.mm_config.EnableSignUpWithGoogle === 'true' ? numMethods + 1 : numMethods;
+ numMethods = global.window.mm_config.EnableLdap === 'true' ? numMethods + 1 : numMethods;
- if (global.window.mm_config.EnableSignUpWithEmail && numMethods > 0) {
+ let signInSection;
+ if (global.window.mm_config.EnableSignUpWithEmail === 'true' && numMethods > 0) {
signInSection = this.createSignInSection();
}
+ let mfaSection;
+ if (global.window.mm_config.EnableMultifactorAuthentication === 'true' && global.window.mm_license.IsLicensed === 'true') {
+ mfaSection = this.createMfaSection();
+ }
+
return (
<div>
<div className='modal-header'>
@@ -459,6 +726,8 @@ class SecurityTab extends React.Component {
<div className='divider-dark first'/>
{passwordSection}
<div className='divider-light'/>
+ {mfaSection}
+ <div className='divider-light'/>
{signInSection}
<div className='divider-dark'/>
<br></br>
diff --git a/webapp/components/user_settings/user_settings_theme.jsx b/webapp/components/user_settings/user_settings_theme.jsx
index 3414fe2e2..14991037d 100644
--- a/webapp/components/user_settings/user_settings_theme.jsx
+++ b/webapp/components/user_settings/user_settings_theme.jsx
@@ -40,7 +40,6 @@ export default class ThemeSetting extends React.Component {
this.onChange = this.onChange.bind(this);
this.submitTheme = this.submitTheme.bind(this);
this.updateTheme = this.updateTheme.bind(this);
- this.deactivate = this.deactivate.bind(this);
this.resetFields = this.resetFields.bind(this);
this.handleImportModal = this.handleImportModal.bind(this);
@@ -62,12 +61,17 @@ export default class ThemeSetting extends React.Component {
}
}
componentWillReceiveProps(nextProps) {
- if (!this.props.selected && nextProps.selected) {
+ if (this.props.selected && !nextProps.selected) {
this.resetFields();
}
}
componentWillUnmount() {
UserStore.removeChangeListener(this.onChange);
+
+ if (this.props.selected) {
+ const state = this.getStateFromStores();
+ Utils.applyTheme(state.theme);
+ }
}
getStateFromStores() {
const user = UserStore.getCurrentUser();
@@ -147,11 +151,6 @@ export default class ThemeSetting extends React.Component {
updateType(type) {
this.setState({type});
}
- deactivate() {
- const state = this.getStateFromStores();
-
- Utils.applyTheme(state.theme);
- }
resetFields() {
const state = this.getStateFromStores();
state.serverError = null;
diff --git a/webapp/dispatcher/app_dispatcher.jsx b/webapp/dispatcher/app_dispatcher.jsx
index dcc43129b..5e43d3ad7 100644
--- a/webapp/dispatcher/app_dispatcher.jsx
+++ b/webapp/dispatcher/app_dispatcher.jsx
@@ -8,6 +8,10 @@ const PayloadSources = Constants.PayloadSources;
const AppDispatcher = Object.assign(new Flux.Dispatcher(), {
handleServerAction: function performServerAction(action) {
+ if (!action.type) {
+ console.warning('handleServerAction called with undefined action type'); // eslint-disable-line no-console
+ }
+
var payload = {
source: PayloadSources.SERVER_ACTION,
action
@@ -16,6 +20,10 @@ const AppDispatcher = Object.assign(new Flux.Dispatcher(), {
},
handleViewAction: function performViewAction(action) {
+ if (!action.type) {
+ console.warning('handleViewAction called with undefined action type'); // eslint-disable-line no-console
+ }
+
var payload = {
source: PayloadSources.VIEW_ACTION,
action
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 00f4f333d..7dc6486ab 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -3,12 +3,17 @@
"about.date": "Build Date:",
"about.enterpriseEditione1": "Enterprise Edition",
"about.hash": "Build Hash:",
+ "about.copyright": "Copyright 2016 Mattermost, Inc. All rights reserved",
"about.licensed": "Licensed by:",
"about.number": "Build Number:",
"about.teamEditiont0": "Team Edition",
"about.teamEditiont1": "Enterprise Edition",
"about.title": "About Mattermost",
"about.version": "Version:",
+ "about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.",
+ "about.teamEditionLearn": "Join the Mattermost community at ",
+ "about.enterpriseEditionSt": "Modern enterprise communication from behind your firewall.",
+ "about.enterpriseEditionLearn": "Learn more about Enterprise Edition at ",
"access_history.title": "Access History",
"activity_log.activeSessions": "Active Sessions",
"activity_log.browser": "Browser: {browser}",
@@ -22,16 +27,40 @@
"activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "Android Native App",
"activity_log_modal.iphoneNativeApp": "iPhone Native App",
+ "add_incoming_webhook.cancel": "Cancel",
+ "add_incoming_webhook.channel": "Channel",
+ "add_incoming_webhook.channelRequired": "A valid channel is required",
+ "add_incoming_webhook.description": "Description",
+ "add_incoming_webhook.header": "Add Incoming Webhook",
+ "add_incoming_webhook.name": "Name",
+ "add_incoming_webhook.save": "Save",
+ "add_integration.header": "Add Integration",
+ "add_integration.incomingWebhook.description": "Create webhook URLs for use in external integrations.",
+ "add_integration.incomingWebhook.title": "Incoming Webhook",
+ "add_integration.outgoingWebhook.description": "Create webhooks to send new message events to an external integration.",
+ "add_integration.outgoingWebhook.title": "Outgoing Webhook",
+ "add_outgoing_webhook.callbackUrls": "Callback URLs (One Per Line)",
+ "add_outgoing_webhook.callbackUrlsRequired": "One or more callback URLs are required",
+ "add_outgoing_webhook.cancel": "Cancel",
+ "add_outgoing_webhook.channel": "Channel",
+ "add_outgoing_webhook.description": "Description",
+ "add_outgoing_webhook.header": "Add Outgoing Webhook",
+ "add_outgoing_webhook.name": "Name",
+ "add_outgoing_webhook.save": "Save",
+ "add_outgoing_webhook.triggerWOrds": "Trigger Words (One Per Line)",
+ "add_outgoing_webhook.triggerWords": "Trigger Words (One Per Line)",
+ "add_outgoing_webhook.triggerWordsOrChannelRequired": "A valid channel or a list of trigger words is required",
"admin.audits.reload": "Reload",
"admin.audits.title": "User Activity",
"admin.compliance.directoryDescription": "Directory to which compliance reports are written. If blank, will be set to ./data/.",
"admin.compliance.directoryExample": "Ex \"./data/\"",
"admin.compliance.directoryTitle": "Compliance Directory Location:",
+ "admin.compliance.enableDailyDesc": "When true, Mattermost will generate a daily compliance report.",
"admin.compliance.enableDailyTitle": "Enable Daily Report:",
- "admin.compliance.enableDesc": "When true, Mattermost will generate a daily compliance report.",
+ "admin.compliance.enableDesc": "When true, Mattermost allows compliance reporting",
"admin.compliance.enableTitle": "Enable Compliance:",
"admin.compliance.false": "false",
- "admin.compliance.noLicense": "<h4 class=\"banner__heading\">Note:</h4><p>Compliance is an enterprise feature. Your current license does not support Compliance. Click <a href=\"http://mattermost.com\" target=\"_blank\">here</a> for information and pricing on enterprise licenses.</p>",
+ "admin.compliance.noLicense": "<h4 class=\"banner__heading\">Note:</h4><p>Compliance is an enterprise feature. Your current license does not support Compliance. Click <a href=\"http://mattermost.com\"target=\"_blank\">here</a> for information and pricing on enterprise licenses.</p>",
"admin.compliance.save": "Save",
"admin.compliance.saving": "Saving Config...",
"admin.compliance.title": "Compliance Settings",
@@ -211,7 +240,7 @@
"admin.ldap.lastnameAttrDesc": "The attribute in the LDAP server that will be used to populate the last name of users in Mattermost.",
"admin.ldap.lastnameAttrEx": "Ex \"sn\"",
"admin.ldap.lastnameAttrTitle": "Last Name Attribute:",
- "admin.ldap.noLicense": "<h4 class=\"banner__heading\">Note:</h4><p>LDAP is an enterprise feature. Your current license does not support LDAP. Click <a href=\"http://mattermost.com\" target=\"_blank\">here</a> for information and pricing on enterprise licenses.</p>",
+ "admin.ldap.noLicense": "<h4 class=\"banner__heading\">Note:</h4><p>LDAP is an enterprise feature. Your current license does not support LDAP. Click <a href=\"http://mattermost.com\"target=\"_blank\">here</a> for information and pricing on enterprise licenses.</p>",
"admin.ldap.portDesc": "The port Mattermost will use to connect to the LDAP server. Default is 389.",
"admin.ldap.portEx": "Ex \"389\"",
"admin.ldap.portTitle": "LDAP Port:",
@@ -229,7 +258,10 @@
"admin.ldap.usernameAttrEx": "Ex \"sAMAccountName\"",
"admin.ldap.usernameAttrTitle": "Username Attribute:",
"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, <a href=\"http://mattermost.com\" target=\"_blank\">disable all Enterprise Edition features on this server</a>. This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.",
+ "admin.license.choose": "Choose File",
"admin.license.chooseFile": "Choose File",
+ "admin.license.edition": "Edition: ",
+ "admin.license.key": "License Key: ",
"admin.license.keyRemove": "Remove Enterprise License and Downgrade Server",
"admin.license.noFile": "No file uploaded",
"admin.license.removing": "Removing License...",
@@ -311,8 +343,8 @@
"admin.service.attemptTitle": "Maximum Login Attempts:",
"admin.service.cmdsDesc": "When true, user created slash commands will be allowed.",
"admin.service.cmdsTitle": "Enable Slash Commands: ",
- "admin.service.corsDescription": "Enable HTTP Cross origin request from specific domains (separate by a spacebar). Use \"*\" if you want to allow CORS from any domain or leave it blank to disable it.",
- "admin.service.corsEx": "http://example.com https://example.com",
+ "admin.service.corsDescription": "Enable HTTP Cross origin request from a specific domain. Use \"*\" if you want to allow CORS from any domain or leave it blank to disable it.",
+ "admin.service.corsEx": "http://example.com",
"admin.service.corsTitle": "Allow Cross-origin Requests from:",
"admin.service.developerDesc": "(Developer Option) When true, extra information around errors will be displayed in the UI.",
"admin.service.developerTitle": "Enable Developer Mode: ",
@@ -329,6 +361,8 @@
"admin.service.listenAddress": "Listen Address:",
"admin.service.listenDescription": "The address to which to bind and listen. Entering \":8065\" will bind to all interfaces or you can choose one like \"127.0.0.1:8065\". Changing this will require a server restart before taking effect.",
"admin.service.listenExample": "Ex \":8065\"",
+ "admin.service.mfaDesc": "When true, users will be given the option to add multi-factor authentication to their account. They will need a smartphone and an authenticator app such as Google Authenticator.",
+ "admin.service.mfaTitle": "Enable Multi-factor Authentication:",
"admin.service.mobileSessionDays": "Session Length for Mobile Device in Days:",
"admin.service.mobileSessionDaysDesc": "The native mobile session will expire after the number of days specified and will require a user to login again.",
"admin.service.outWebhooksDesc": "When true, outgoing webhooks will be allowed.",
@@ -488,6 +522,7 @@
"analytics.team.privateGroups": "Private Groups",
"analytics.team.publicChannels": "Public Channels",
"analytics.team.recentActive": "Recent Active Users",
+ "analytics.team.recentUsers": "Recent Active Users",
"analytics.team.title": "Team Statistics for {team}",
"analytics.team.totalPosts": "Total Posts",
"analytics.team.totalUsers": "Total Users",
@@ -550,6 +585,12 @@
"authorize.app": "The app <strong>{appName}</strong> would like the ability to access and modify your basic information.",
"authorize.deny": "Deny",
"authorize.title": "An application would like to connect to your {teamName} account",
+ "backstage_navbar.backToMattermost": "Back to {siteName}",
+ "backstage_sidebar.integrations": "Integrations",
+ "backstage_sidebar.integrations.add": "Add Integration",
+ "backstage_sidebar.integrations.add.incomingWebhook": "Incoming Webhook",
+ "backstage_sidebar.integrations.add.outgoingWebhook": "Outgoing Webhook",
+ "backstage_sidebar.integrations.installed": "Installed Integrations",
"center_panel.recent": "Click here to jump to recent messages. ",
"chanel_header.addMembers": "Add Members",
"change_url.close": "Close",
@@ -626,6 +667,7 @@
"channel_notifications.preferences": "Notification Preferences for ",
"channel_notifications.sendDesktop": "Send desktop notifications",
"channel_notifications.unreadInfo": "The channel name is bolded in the sidebar when there are unread messages. Selecting \"Only for mentions\" will bold the channel only when you are mentioned.",
+ "channel_select.placeholder": "--- Select a channel ---",
"choose_auth_page.emailCreate": "Create new team with email address",
"choose_auth_page.find": "Find my teams",
"choose_auth_page.gitlabCreate": "Create new team with GitLab Account",
@@ -671,6 +713,7 @@
"claim.oauth_to_email.pwdNotMatch": "Password do not match.",
"claim.oauth_to_email.switchTo": "Switch {type} to email and password",
"claim.oauth_to_email.title": "Switch {type} Account to Email",
+ "claim.oauth_to_email_newPwd": "Enter a new password for your {team} {site} account",
"confirm_modal.cancel": "Cancel",
"create_comment.addComment": "Add a comment...",
"create_comment.comment": "Add Comment",
@@ -732,8 +775,9 @@
"file_upload.filesAbove": "Files above {max}MB could not be uploaded: {filenames}",
"file_upload.limited": "Uploads limited to {count} files maximum. Please use additional posts for more files.",
"file_upload.pasted": "Image Pasted at ",
- "filtered_user_list.count": "{count, number} {count, plural, one {member} other {members}}",
- "filtered_user_list.countTotal": "{count, number} {count, plural, one {member} other {members}} of {total} Total",
+ "filtered_user_list.count": "{count} {count, plural, one {member} other {members}}",
+ "filtered_user_list.countTotal": "{count} {count, plural, one {member} other {members}} of {total} Total",
+ "filtered_user_list.member": "Member",
"filtered_user_list.search": "Search members",
"find_team.email": "Email",
"find_team.findDescription": "An email was sent with links to any teams to which you are a member.",
@@ -768,6 +812,16 @@
"get_team_invite_link_modal.help": "Send teammates the link below for them to sign-up to this team site. The Team Invite Link can be shared with multiple teammates as it does not change unless it's regenerated in Team Settings by a Team Admin.",
"get_team_invite_link_modal.helpDisabled": "User creation has been disabled for your team. Please ask your team administrator for details.",
"get_team_invite_link_modal.title": "Team Invite Link",
+ "installed_integrations.add": "Add Integration",
+ "installed_integrations.allFilter": "All ({count})",
+ "installed_integrations.delete": "Delete",
+ "installed_integrations.header": "Installed Integrations",
+ "installed_integrations.incomingWebhookType": "(Incoming Webhook)",
+ "installed_integrations.incomingWebhooksFilter": "Incoming Webhooks ({count})",
+ "installed_integrations.outgoingWebhookType": "(Outgoing Webhook)",
+ "installed_integrations.outgoingWebhooksFilter": "Outgoing Webhooks ({count})",
+ "installed_integrations.regenToken": "Regen Token",
+ "installed_integrations.search": "Search Integrations",
"intro_messages.DM": "This is the start of your direct message history with {teammate}.<br />Direct messages and files shared here are not shown to people outside this area.",
"intro_messages.anyMember": " Any member can join and read this channel.",
"intro_messages.beginning": "Beginning of {name}",
@@ -830,6 +884,10 @@
"login_ldap.pwdReq": "An LDAP password is required",
"login_ldap.signin": "Sign in",
"login_ldap.username": "LDAP Username",
+ "login_mfa.enterToken": "To complete the sign in process, please enter a token from your smartphone's authenticator",
+ "login_mfa.submit": "Submit",
+ "login_mfa.token": "MFA Token",
+ "login_mfa.tokenReq": "Please enter an MFA token",
"login_username.badTeam": "Bad team name",
"login_username.pwd": "Password",
"login_username.pwdReq": "A password is required",
@@ -873,6 +931,7 @@
"navbar_dropdown.console": "System Console",
"navbar_dropdown.create": "Create a New Team",
"navbar_dropdown.help": "Help",
+ "navbar_dropdown.integrations": "Integrations",
"navbar_dropdown.inviteMember": "Invite New Member",
"navbar_dropdown.logout": "Logout",
"navbar_dropdown.manageMembers": "Manage Members",
@@ -888,7 +947,7 @@
"password_form.title": "Password Reset",
"password_form.update": "Your password has been updated successfully.",
"password_send.checkInbox": "Please check your inbox.",
- "password_send.description": "To reset your password, enter the email address you used to sign up.",
+ "password_send.description": "To reset your password, enter the email address you used to sign up",
"password_send.email": "Email",
"password_send.error": "Please enter a valid email address.",
"password_send.link": "<p>A password reset link has been sent to <b>{email}</b></p>",
@@ -985,7 +1044,7 @@
"sidebar.pg": "Private Groups",
"sidebar.removeList": "Remove from list",
"sidebar.tutorialScreen1": "<h4>Channels</h4><p><strong>Channels</strong> organize conversations across different topics. They’re open to everyone on your team. To send private communications use <strong>Direct Messages</strong> for a single person or <strong>Private Groups</strong> for multiple people.</p>",
- "sidebar.tutorialScreen2": "<h4>\"Town Square\" and \"Off-Topic\" channels</h4><p>Here are two public channels to start:</p><p><strong>Town Square</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p><p><strong>Off-Topic</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>",
+ "sidebar.tutorialScreen2": "<h4>\"{townsquare}\" and \"{offtopic}\" channels</h4><p>Here are two public channels to start:</p><p><strong>{townsquare}</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p><p><strong>{offtopic}</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>",
"sidebar.tutorialScreen3": "<h4>Creating and Joining Channels</h4><p>Click <strong>\"More...\"</strong> to create a new channel or join an existing one.</p><p>You can also create a new channel or private group by clicking the <strong>\"+\" symbol</strong> next to the channel or private group header.</p>",
"sidebar.unreadAbove": "Unread post(s) above",
"sidebar.unreadBelow": "Unread post(s) below",
@@ -1027,6 +1086,7 @@
"signup_user_completed.validEmail": "Please enter a valid email address",
"signup_user_completed.welcome": "Welcome to:",
"signup_user_completed.whatis": "What's your email address?",
+ "signup_user_completed.withLdap": "With your LDAP credentials",
"sso_signup.find": "Find my teams",
"sso_signup.gitlab": "Create team with GitLab Account",
"sso_signup.google": "Create team with Google Apps Account",
@@ -1133,7 +1193,7 @@
"textbox.quote": ">quote",
"textbox.strike": "strike",
"tutorial_intro.allSet": "You’re all set",
- "tutorial_intro.end": "Click β€œNext” to enter Town Square. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.",
+ "tutorial_intro.end": "Click β€œNext” to enter {channel}. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.",
"tutorial_intro.invite": "Invite teammates",
"tutorial_intro.next": "Next",
"tutorial_intro.screenOne": "<h3>Welcome to:</h3><h1>Mattermost</h1><p>Your team communication all in one place, instantly searchable and available anywhere</p><p>Keep your team connected to help them achieve what matters most.</p>",
@@ -1238,6 +1298,7 @@
"user.settings.display.theme.customTheme": "Custom Theme",
"user.settings.display.theme.describe": "Open to manage your theme",
"user.settings.display.theme.import": "Import theme colors from Slack",
+ "user.settings.display.theme.otherThemes": "See other themes",
"user.settings.display.theme.themeColors": "Theme Colors",
"user.settings.display.theme.title": "Theme",
"user.settings.display.title": "Display Settings",
@@ -1246,18 +1307,22 @@
"user.settings.general.close": "Close",
"user.settings.general.confirmEmail": "Confirm Email",
"user.settings.general.email": "Email",
- "user.settings.general.emailCantUpdate": "Log in occurs through GitLab. Email cannot be updated.",
+ "user.settings.general.emailGitlabCantUpdate": "Login occurs through GitLab. Email cannot be updated. Email address used for notifications is {email}.",
"user.settings.general.emailHelp1": "Email is used for sign-in, notifications, and password reset. Email requires verification if changed.",
"user.settings.general.emailHelp2": "Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.",
"user.settings.general.emailHelp3": "Email is used for sign-in, notifications, and password reset.",
"user.settings.general.emailHelp4": "A verification email was sent to {email}.",
+ "user.settings.general.emailLdapCantUpdate": "Login occurs through LDAP. Email cannot be updated. Email address used for notifications is {email}.",
"user.settings.general.emailMatch": "The new emails you entered do not match.",
+ "user.settings.general.emptyName": "Click 'Edit' to add your full name",
+ "user.settings.general.emptyNickname": "Click 'Edit' to add a nickname",
"user.settings.general.firstName": "First Name",
"user.settings.general.fullName": "Full Name",
"user.settings.general.imageTooLarge": "Unable to upload profile image. File is too large.",
"user.settings.general.imageUpdated": "Image last updated {date}",
"user.settings.general.lastName": "Last Name",
- "user.settings.general.loginGitlab": "Log in done through GitLab",
+ "user.settings.general.loginGitlab": "Login done through GitLab ({email})",
+ "user.settings.general.loginLdap": "Login done through LDAP ({email})",
"user.settings.general.newAddress": "New Address: {email}<br />Check your email to verify the above address.",
"user.settings.general.nickname": "Nickname",
"user.settings.general.nicknameExtra": "Use Nickname for a name you might be called that is different from your first name and username. This is most often used when two or more people have similar sounding names and usernames.",
@@ -1273,27 +1338,6 @@
"user.settings.general.usernameRestrictions": "Username must begin with a letter, and contain between {min} to {max} lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'.",
"user.settings.general.validEmail": "Please enter a valid email address",
"user.settings.general.validImage": "Only JPG or PNG images may be used for profile pictures",
- "user.settings.hooks_in.add": "Add",
- "user.settings.hooks_in.addTitle": "Add a new incoming webhook",
- "user.settings.hooks_in.channel": "Channel: ",
- "user.settings.hooks_in.description": "Create webhook URLs for use in external integrations. Please see <a href=\"http://docs.mattermost.com/developer/webhooks-incoming.html\" target=\"_blank\">incoming webhooks documentation</a> to learn more. View all incoming webhooks configured on this team below.",
- "user.settings.hooks_in.existing": "Existing incoming webhooks",
- "user.settings.hooks_in.none": "None",
- "user.settings.hooks_out.add": "Add",
- "user.settings.hooks_out.addDescription": "Create webhooks to send new message events to an external integration. Please see <a href=\"http://docs.mattermost.com/developer/webhooks-outgoing.html\" target=\"_blank\">outgoing webhooks documentation</a> to learn more. View all outgoing webhooks configured on this team below.",
- "user.settings.hooks_out.addTitle": "Add a new outgoing webhook",
- "user.settings.hooks_out.callback": "Callback URLs: ",
- "user.settings.hooks_out.callbackDesc": "New line separated URLs that will receive the HTTP POST event",
- "user.settings.hooks_out.callbackHolder": "Each URL must start with http:// or https://",
- "user.settings.hooks_out.channel": "Channel: ",
- "user.settings.hooks_out.comma": "Comma separated words to trigger on",
- "user.settings.hooks_out.existing": "Existing outgoing webhooks",
- "user.settings.hooks_out.none": "None",
- "user.settings.hooks_out.only": "Only public channels can be used",
- "user.settings.hooks_out.optional": "Optional if channel selected",
- "user.settings.hooks_out.regen": "Regen Token",
- "user.settings.hooks_out.select": "--- Select a channel ---",
- "user.settings.hooks_out.trigger": "Trigger Words: ",
"user.settings.import_theme.cancel": "Cancel",
"user.settings.import_theme.importBody": "To import a theme, go to a Slack team and look for β€œPreferences -> Sidebar Theme”. Open the custom theme option, copy the theme color values and paste them here:",
"user.settings.import_theme.importHeader": "Import Slack Theme",
@@ -1301,12 +1345,15 @@
"user.settings.import_theme.submitError": "Invalid format, please try copying and pasting in again.",
"user.settings.integrations.commands": "Slash Commands",
"user.settings.integrations.commandsDescription": "Manage your slash commands",
- "user.settings.integrations.incomingWebhooks": "Incoming Webhooks",
- "user.settings.integrations.incomingWebhooksDescription": "Manage your incoming webhooks",
- "user.settings.integrations.outWebhooks": "Outgoing Webhooks",
- "user.settings.integrations.outWebhooksDescription": "Manage your outgoing webhooks",
"user.settings.integrations.title": "Integration Settings",
"user.settings.languages.change": "Change interface language",
+ "user.settings.mfa.add": "Add MFA to your account",
+ "user.settings.mfa.addHelp": "To add multi-factor authentication to your account you must have a smartphone with Google Authenticator installed.",
+ "user.settings.mfa.addHelpQr": "Please scan the QR code with the Google Authenticator app on your smartphone and fill in the token with one provided by the app.",
+ "user.settings.mfa.enterToken": "Token",
+ "user.settings.mfa.qrCode": "QR Code",
+ "user.settings.mfa.remove": "Remove MFA from your account",
+ "user.settings.mfa.removeHelp": "Removing multi-factor authentication will make your account more vulnerable to attacks.",
"user.settings.modal.advanced": "Advanced",
"user.settings.modal.confirmBtns": "Yes, Discard",
"user.settings.modal.confirmMsg": "You have unsaved changes, are you sure you want to discard them?",
@@ -1347,18 +1394,22 @@
"user.settings.security.emailPwd": "Email and Password",
"user.settings.security.gitlab": "GitLab SSO",
"user.settings.security.lastUpdated": "Last updated {date} at {time}",
+ "user.settings.security.loginGitlab": "Login done through Gitlab",
+ "user.settings.security.loginLdap": "Login done through LDAP",
"user.settings.security.logoutActiveSessions": "View and Logout of Active Sessions",
"user.settings.security.method": "Sign-in Method",
"user.settings.security.newPassword": "New Password",
"user.settings.security.oneSignin": "You may only have one sign-in method at a time. Switching sign-in method will send an email notifying you if the change was successful.",
"user.settings.security.password": "Password",
+ "user.settings.security.passwordGitlabCantUpdate": "Login occurs through GitLab. Password cannot be updated.",
+ "user.settings.security.passwordLdapCantUpdate": "Login occurs through LDAP. Password cannot be updated.",
"user.settings.security.passwordLengthError": "New passwords must be at least {chars} characters",
"user.settings.security.passwordMatchError": "The new passwords you entered do not match",
"user.settings.security.retypePassword": "Retype New Password",
"user.settings.security.switchEmail": "Switch to using email and password",
"user.settings.security.switchGitlab": "Switch to using GitLab SSO",
"user.settings.security.switchGoogle": "Switch to using Google SSO",
- "user.settings.security.switchLda": "Switch to using LDAP",
+ "user.settings.security.switchLdap": "Switch to using LDAP",
"user.settings.security.title": "Security Settings",
"user.settings.security.viewHistory": "View Access History",
"user_list.notFound": "No users found :(",
diff --git a/webapp/i18n/es.json b/webapp/i18n/es.json
index 20b79fc84..8cc9e5db6 100644
--- a/webapp/i18n/es.json
+++ b/webapp/i18n/es.json
@@ -22,13 +22,37 @@
"activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "Android App Nativa",
"activity_log_modal.iphoneNativeApp": "iPhone App Nativa",
+ "add_incoming_webhook.cancel": "Cancelar",
+ "add_incoming_webhook.channel": "Canal",
+ "add_incoming_webhook.channelRequired": "Es obligatorio asignar un canal vΓ‘lido",
+ "add_incoming_webhook.description": "DescripciΓ³n",
+ "add_incoming_webhook.header": "Agregar un Webhook de Entrada",
+ "add_incoming_webhook.name": "Nombre",
+ "add_incoming_webhook.save": "Guardar",
+ "add_integration.header": "Agregar IntegraciΓ³n",
+ "add_integration.incomingWebhook.description": "Crea webhook URLs para utilizarlas con integraciones externas.",
+ "add_integration.incomingWebhook.title": "Webhook de Entrada",
+ "add_integration.outgoingWebhook.description": "Crea webhooks para enviar mensajes a integraciones externas.",
+ "add_integration.outgoingWebhook.title": "Webhook de salida",
+ "add_outgoing_webhook.callbackUrls": "Callback URLs (Uno por LΓ­nea)",
+ "add_outgoing_webhook.callbackUrlsRequired": "Se require uno o mΓ‘s URLs para los callback",
+ "add_outgoing_webhook.cancel": "Cancelar",
+ "add_outgoing_webhook.channel": "Canal",
+ "add_outgoing_webhook.description": "DescripciΓ³n",
+ "add_outgoing_webhook.header": "Agregar Webhook de Salida",
+ "add_outgoing_webhook.name": "Nombre",
+ "add_outgoing_webhook.save": "Guardar",
+ "add_outgoing_webhook.triggerWOrds": "Palabras gatilladoras (Una por lΓ­nea)",
+ "add_outgoing_webhook.triggerWords": "Palabras gatilladoras (Una por LΓ­nea)",
+ "add_outgoing_webhook.triggerWordsOrChannelRequired": "Se require al menos un canal vΓ‘lido o una lista de palabras gatilladoras",
"admin.audits.reload": "Recargar",
"admin.audits.title": "AuditorΓ­as del Servidor",
"admin.compliance.directoryDescription": "Directorio en el que se escriben los informes de cumplimiento. Si se deja en blanco, se utilizarΓ‘ ./data/.",
"admin.compliance.directoryExample": "Ej \"./data/\"",
"admin.compliance.directoryTitle": "UbicaciΓ³n del Directorio de Cumplimiento:",
+ "admin.compliance.enableDailyDesc": "Cuando es verdadero, Mattermost generarΓ‘ un reporte de cumplimiento diario.",
"admin.compliance.enableDailyTitle": "Habilitar Informes Diarios:",
- "admin.compliance.enableDesc": "Cuando es verdadero, Mattermost generarΓ‘ un informe diario de cumplimiento.",
+ "admin.compliance.enableDesc": "Cuando es verdadero, Mattermost permite la creaciΓ³n de reportes de cumplimiento",
"admin.compliance.enableTitle": "Habilitar el Cumplimiento:",
"admin.compliance.false": "falso",
"admin.compliance.noLicense": "<h4 class=\"banner__heading\">Nota:</h4><p>El Cumplimiento es una caracterΓ­stica de la ediciΓ³n enterprise. Tu licencia actual no soporta Cumplimiento. Pincha <a href=\"http://mattermost.com\" target=\"_blank\">aquΓ­</a> para informaciΓ³n y precio de las licencias enterprise.</p>",
@@ -211,7 +235,7 @@
"admin.ldap.lastnameAttrDesc": "El atributo en el servidor LDAP que serΓ‘ utilizado para poblar el apellido de los usuarios en Mattermost.",
"admin.ldap.lastnameAttrEx": "Ej \"sn\"",
"admin.ldap.lastnameAttrTitle": "Atributo Apellido:",
- "admin.ldap.noLicense": "<h4 class=\"banner__heading\">Nota:</h4><p>LDAP es una caracterΓ­stica de la ediciΓ³n enterprise. Tu licencia actual no soporta LDAP. Pincha <a href=\"http://mattermost.com\" target=\"_blank\">aquΓ­</a> para obtener informaciΓ³n y precios de las licencias de la ediciΓ³n enterprise.</p>",
+ "admin.ldap.noLicense": "<h4 class=\"banner__heading\">Nota:</h4><p>LDAP es una caracterΓ­stica de la ediciΓ³n enterprise. Tu licencia actual no soporta LDAP. Pincha <a href=\"http://mattermost.com\" target=\"_blank\">aquΓ­</a> para obtener informaciΓ³n y precios de las licencias enterprise.</p>",
"admin.ldap.portDesc": "El puerto que Mattermost utilizarΓ‘ para conectarse al servidor LDAP. El predeterminado es 389.",
"admin.ldap.portEx": "Ej \"389\"",
"admin.ldap.portTitle": "Puerto LDAP:",
@@ -229,7 +253,10 @@
"admin.ldap.usernameAttrEx": "Ej \"sAMAccountName\"",
"admin.ldap.usernameAttrTitle": "Atributo Usuario:",
"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, <a href=\"http://mattermost.com\" target=\"_blank\">deshabilita todas las caracterΓ­sticas de la EdiciΓ³n Enterprise de este servidor</a>. 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.choose": "Seleccionar Archivo",
"admin.license.chooseFile": "Escoger Archivo",
+ "admin.license.edition": "EdiciΓ³n: ",
+ "admin.license.key": "Licencia: ",
"admin.license.keyRemove": "Remover la Licencia Enterprise y Degradar el Servidor",
"admin.license.noFile": "No se subiΓ³ ningΓΊn archivo",
"admin.license.removing": "Removiendo Licencia...",
@@ -311,8 +338,8 @@
"admin.service.attemptTitle": "MΓ‘ximo de intentos de conexiΓ³n:",
"admin.service.cmdsDesc": "Cuando es verdadero, se permite la creaciΓ³n de comandos de barra por usuarios.",
"admin.service.cmdsTitle": "Habilitar Comandos de Barra: ",
- "admin.service.corsDescription": "Habilita las solicitudes HTTP de origen cruzado para dominios en especΓ­fico (separados por un espacio). Utiliza \"*\" si quieres habilitar CORS desde cualquier dominio o deja el campo en blanco para deshabilitarlo.",
- "admin.service.corsEx": "http://ejemplo.com https://ejemplo.com",
+ "admin.service.corsDescription": "Habilitar solicitudes HTTP de origen cruzado desde un dominio especΓ­fico. Utiliza \"*\" si quieres permitir CORS desde cualquier dominio o dejalo en blanco para deshabilitarlo.",
+ "admin.service.corsEx": "http://ejemplo.com",
"admin.service.corsTitle": "Permitir Solicitudes de Origen Cruzado desde:",
"admin.service.developerDesc": "(OpciΓ³n de Desarrollador) Cuando estΓ‘ asignado en verdadero, informaciΓ³n extra sobre errores se muestra en el UI.",
"admin.service.developerTitle": "Habilitar modo de Desarrollador: ",
@@ -329,6 +356,8 @@
"admin.service.listenAddress": "DirecciΓ³n de escucha:",
"admin.service.listenDescription": "La direcciΓ³n a la que se unirΓ‘ y escucharΓ‘. Ingresar \":8065\" se podrΓ‘ unir a todas las interfaces o podrΓ‘ seleccionar una como ej: \"127.0.0.1:8065\". Cambiando este valor es necesario reiniciar el servidor.",
"admin.service.listenExample": "Ej \":8065\"",
+ "admin.service.mfaDesc": "Cuando es verdadero, los usuarios tendrΓ‘n la opciΓ³n de agregar autenticaciΓ³n de mΓΊltiples factores a sus cuentas. NecesitarΓ‘n un telΓ©fono inteligente y una app de autenticaciΓ³n como Google Authenticator.",
+ "admin.service.mfaTitle": "Habilitar AutenticaciΓ³n de MΓΊltiples Factores:",
"admin.service.mobileSessionDays": "DuraciΓ³n de la SesiΓ³n en DΓ­as para Dispositivos Moviles:",
"admin.service.mobileSessionDaysDesc": "La sesiΓ³n nativa de los dispositivos moviles expirarΓ‘ luego de transcurrido el numero de dΓ­as especificado y se solicitarΓ‘ al usuario que inicie sesiΓ³n nuevamente.",
"admin.service.outWebhooksDesc": "Cuando es verdadero, los webhooks de salida serΓ‘n permitidos.",
@@ -488,6 +517,7 @@
"analytics.team.privateGroups": "Grupos Privados",
"analytics.team.publicChannels": "Canales PΓΊblicos",
"analytics.team.recentActive": "Usuarios Recientemente Activos",
+ "analytics.team.recentUsers": "Usuarios Recientemente Activos",
"analytics.team.title": "EstΓ‘disticas del Equipo {team}",
"analytics.team.totalPosts": "Total de Mensajes",
"analytics.team.totalUsers": "Total de Usuarios",
@@ -550,6 +580,12 @@
"authorize.app": "La app <strong>{appName}</strong> quiere tener la habilidad de accesar y modificar tu informaciΓ³n bΓ‘sica.",
"authorize.deny": "Denegar",
"authorize.title": "Una aplicaciΓ³n quiere conectarse con tu cuenta de {teamName}",
+ "backstage_navbar.backToMattermost": "Volver a {siteName}",
+ "backstage_sidebar.integrations": "Integraciones",
+ "backstage_sidebar.integrations.add": "Agregar IntegraciΓ³n",
+ "backstage_sidebar.integrations.add.incomingWebhook": "Webhook de Entrada",
+ "backstage_sidebar.integrations.add.outgoingWebhook": "Webhook de Salida",
+ "backstage_sidebar.integrations.installed": "Integraciones Instaladas",
"center_panel.recent": "Pincha aquΓ­ para ir a los mensajes mΓ‘s recientes. ",
"chanel_header.addMembers": "Agregar Miembros",
"change_url.close": "Cerrar",
@@ -626,6 +662,7 @@
"channel_notifications.preferences": "Preferencias de NotificaciΓ³n para ",
"channel_notifications.sendDesktop": "Enviar notificaciones de escritorio",
"channel_notifications.unreadInfo": "El nombre del canal estΓ‘ en negritas en la barra lateral cuando hay mensajes sin leer. Al elegir \"SΓ³lo para menciones\" sΓ³lo lo dejarΓ‘ en negritas cuando seas mencionado.",
+ "channel_select.placeholder": "--- Selecciona un canal ---",
"choose_auth_page.emailCreate": "Crea un nuevo equipo con tu cuenta de correo",
"choose_auth_page.find": "Encontrar mi equipo",
"choose_auth_page.gitlabCreate": "Crear un nuevo equipo con una cuenta de GitLab",
@@ -671,6 +708,7 @@
"claim.oauth_to_email.pwdNotMatch": "Las contraseΓ±as no coinciden.",
"claim.oauth_to_email.switchTo": "Cambiar {type} a correo electrΓ³nico y contraseΓ±a",
"claim.oauth_to_email.title": "Cambiar la cuenta de {type} a Correo ElectrΓ³nico",
+ "claim.oauth_to_email_newPwd": "Ingresa una nueva contraseΓ±a para tu cuenta de {team} en {site}",
"confirm_modal.cancel": "Cancelar",
"create_comment.addComment": "Agregar un comentario...",
"create_comment.comment": "Agregar Comentario",
@@ -732,8 +770,9 @@
"file_upload.filesAbove": "No se pueden subir archivos de mΓ‘s de {max}MB: {filenames}",
"file_upload.limited": "Se pueden subir un mΓ‘ximo de {count} archivos. Por favor envΓ­a otros mensajes para adjuntar mΓ‘s archivos.",
"file_upload.pasted": "Imagen Pegada el ",
- "filtered_user_list.count": "{count, number} {count, plural, one {Miembro} other {Miembros}}",
- "filtered_user_list.countTotal": "{count, number} {count, plural, one {Miembro} other {Miembros}} de {total} Total",
+ "filtered_user_list.count": "{count} {count, plural, one {miembro} other {miembros}}",
+ "filtered_user_list.countTotal": "{count} {count, plural, one {miembro} other {miembros}} de {total} Total",
+ "filtered_user_list.member": "Miembro",
"filtered_user_list.search": "Buscar miembros",
"find_team.email": "Correo electrΓ³nico",
"find_team.findDescription": "Enviamos un correo electrΓ³nico con los equipos a los que perteneces.",
@@ -768,6 +807,16 @@
"get_team_invite_link_modal.help": "EnvΓ­a el siguiente enlace a tus compaΓ±eros para que se registren a este equipo. El enlace de invitaciΓ³n al equipo puede ser compartido con multiples compaΓ±eros y el mismo no cambiarΓ‘ a menos que sea regenerado en la ConfiguraciΓ³n del Equipo por un Administrador del Equipo.",
"get_team_invite_link_modal.helpDisabled": "La creaciΓ³n de usuario ha sido deshabilitada para tu equipo. Por favor solicita mΓ‘s detalles a tu administrador de equipo.",
"get_team_invite_link_modal.title": "Enlace de InvitaciΓ³n al Equipo",
+ "installed_integrations.add": "Agregar IntegraciΓ³n",
+ "installed_integrations.allFilter": "Todos ({count})",
+ "installed_integrations.delete": "Eliminar",
+ "installed_integrations.header": "Integraciones Instaladas",
+ "installed_integrations.incomingWebhookType": "(Webhook de Entrada)",
+ "installed_integrations.incomingWebhooksFilter": "Webhooks de Entrada ({count})",
+ "installed_integrations.outgoingWebhookType": "(Webhook de Salida)",
+ "installed_integrations.outgoingWebhooksFilter": "Webhooks de Salida ({count})",
+ "installed_integrations.regenToken": "Regenerar Token",
+ "installed_integrations.search": "Buscar Integraciones",
"intro_messages.DM": "Este es el inicio de tu historial de mensajes directos con {teammate}.<br />Los mensajes directos y archivos que se comparten aquΓ­ no son mostrados a personas fuera de esta Γ‘rea.",
"intro_messages.anyMember": " Cualquier miembro se puede unir y leer este canal.",
"intro_messages.beginning": "Inicio de {name}",
@@ -830,6 +879,10 @@
"login_ldap.pwdReq": "La contraseΓ±a LDAP es obligatoria",
"login_ldap.signin": "Entrar",
"login_ldap.username": "Usuario LDAP",
+ "login_mfa.enterToken": "Para completar el proceso de inicio de sesiΓ³n, por favor ingresa el token provisto por el autenticador de tu telΓ©fono inteligente",
+ "login_mfa.submit": "Enviar",
+ "login_mfa.token": "Token AMF",
+ "login_mfa.tokenReq": "Por favor ingresa un token AMF",
"login_username.badTeam": "Mal nombre de equipo",
"login_username.pwd": "ContraseΓ±a",
"login_username.pwdReq": "La contraseΓ±a es obligatoria",
@@ -873,6 +926,7 @@
"navbar_dropdown.console": "Consola de Sistema",
"navbar_dropdown.create": "Crear nuevo Equipo",
"navbar_dropdown.help": "Ayuda",
+ "navbar_dropdown.integrations": "Integraciones",
"navbar_dropdown.inviteMember": "Invitar Nuevo Miembro",
"navbar_dropdown.logout": "Cerrar sesiΓ³n",
"navbar_dropdown.manageMembers": "Administrar Miembros",
@@ -888,7 +942,7 @@
"password_form.title": "Restablecer ContraseΓ±a",
"password_form.update": "Tu contraseΓ±a ha sido actualizada satisfactoriamente.",
"password_send.checkInbox": "Por favor revisa tu bandeja de entrada.",
- "password_send.description": "Para restablecer tu contraseΓ±a, ingresa la direcciΓ³n de correo electrΓ³nico que utilizaste para registrarte.",
+ "password_send.description": "Para reiniciar tu contraseΓ±a, ingresa la direcciΓ³n de correo que utilizaste en el registro",
"password_send.email": "Correo electrΓ³nico",
"password_send.error": "Por favor ingresa una direcciΓ³n correo electrΓ³nico vΓ‘lida.",
"password_send.link": "<p>Se ha enviado un enlace para restablecer la contraseΓ±a a <b>{email}</b></p>",
@@ -985,7 +1039,7 @@
"sidebar.pg": "Grupos Privados",
"sidebar.removeList": "Remover de la lista",
"sidebar.tutorialScreen1": "<h4>Canales</h4><p><strong>Canales</strong> organizan las conversaciones en diferentes tΓ³picos. Son abiertos para cualquier persona de tu equipo. Para enviar comunicaciones privadas con una sola persona utiliza <strong>Mensajes Directos</strong> o con multiples personas utilizando <strong>Grupos Privados</strong>.</p>",
- "sidebar.tutorialScreen2": "<h4>Los canal \"General\" y \"Fuera de TΓ³pico\"</h4><p>Estos son dos canales para comenzar:</p><p><strong>General</strong> es el lugar para tener comunicaciΓ³n con todo el equipo. Todos los integrantes de tu equipo son miembros de este canal.</p><p><strong>Fuera de TΓ³pico</strong> es un lugar para diversiΓ³n y humor fuera de los canales relacionados con el trabajo. Tu y tu equipo pueden decidir que otros canales crear.</p>",
+ "sidebar.tutorialScreen2": "<h4>Los canal \"{townsquare}\" y \"{offtopic}\"</h4><p>Estos son dos canales para comenzar:</p><p><strong>{townsquare}</strong> es el lugar para tener comunicaciΓ³n con todo el equipo. Todos los integrantes de tu equipo son miembros de este canal.</p><p><strong>{offtopic}</strong> es un lugar para diversiΓ³n y humor fuera de los canales relacionados con el trabajo. Tu y tu equipo pueden decidir que otros canales crear.</p>",
"sidebar.tutorialScreen3": "<h4>Creando y Uniendose a Canales</h4><p>Pincha en <strong>\"MΓ‘s...\"</strong> para crear un nuevo canal o unirte a uno existente.</p><p>TambiΓ©n puedes crear un nuevo canal o grupo privado al pinchar el simbolo de <strong>\"+\"</strong> que se encuentra al lado del encabezado de Canales o Grupos Privados.</p>",
"sidebar.unreadAbove": "Mensaje(s) sin leer β–²",
"sidebar.unreadBelow": "Mensaje(s) sin leer β–Ό",
@@ -1027,6 +1081,7 @@
"signup_user_completed.validEmail": "Por favor ingresa una direcciΓ³n de correo electrΓ³nico vΓ‘lida",
"signup_user_completed.welcome": "Bienvenido a:",
"signup_user_completed.whatis": "ΒΏCuΓ‘l es tu direcciΓ³n de correo electrΓ³nico?",
+ "signup_user_completed.withLdap": "Con tus credenciales de LDAP",
"sso_signup.find": "Encontrar mi equipo",
"sso_signup.gitlab": "Crea un equipo con una cuenta de GitLab",
"sso_signup.google": "Crea un equipo con una cuenta de Google Apps",
@@ -1133,7 +1188,7 @@
"textbox.quote": ">cita",
"textbox.strike": "tachado",
"tutorial_intro.allSet": "Ya estΓ‘s listo para comenzar",
- "tutorial_intro.end": "Pincha β€œSiguiente” para entrar al Canal General. Este es el primer canal que ven tus compaΓ±eros cuando ingresan. Utilizalo para mandar mensajes que todos deben leer.",
+ "tutorial_intro.end": "Pincha β€œSiguiente” para entrar al {channel}. Este es el primer canal que ven tus compaΓ±eros cuando ingresan. Utilizalo para mandar mensajes que todos deben leer.",
"tutorial_intro.invite": "Invitar compaΓ±eros",
"tutorial_intro.next": "Siguiente",
"tutorial_intro.screenOne": "<h3>Bienvenido a:</h3> <h1>Mattermost</h1> <p>Las comunicaciones de tu equipo en un solo lugar, con bΓΊsquedas instantΓ‘neas y disponible desde donde sea.</p> <p>MantΓ©n a tu equipo conectado para ayudarlos a conseguir lo que realmente importa.</p>",
@@ -1238,6 +1293,7 @@
"user.settings.display.theme.customTheme": "Tema Personalizado",
"user.settings.display.theme.describe": "Abrir para administrar tu tema",
"user.settings.display.theme.import": "Importar colores del tema desde Slack",
+ "user.settings.display.theme.otherThemes": "Ver otros temas",
"user.settings.display.theme.themeColors": "Colores del Tema",
"user.settings.display.theme.title": "Tema",
"user.settings.display.title": "ConfiguraciΓ³n de VisualizaciΓ³n",
@@ -1246,18 +1302,22 @@
"user.settings.general.close": "Cerrar",
"user.settings.general.confirmEmail": "Confirmar Correo electrΓ³nico",
"user.settings.general.email": "Correo electrΓ³nico",
- "user.settings.general.emailCantUpdate": "El inicio de sesiΓ³n ocurre a travΓ©s de GitLab. El correo electrΓ³nico no puede ser cambiado.",
+ "user.settings.general.emailGitlabCantUpdate": "El inicio de sesiΓ³n ocurre a travΓ©s GitLab. El correo electrΓ³nico no puede ser actualizado. La direcciΓ³n de correo electrΓ³nico utilizada para las notificaciones es {email}.",
"user.settings.general.emailHelp1": "El correo electrΓ³nico es utilizado para iniciar sesiΓ³n, recibir notificaciones y para restablecer la contraseΓ±a. Si se cambia el correo electrΓ³nico deberΓ‘s verificarlo nuevamente.",
"user.settings.general.emailHelp2": "El correo ha sido deshabilitado por el administrador de sistemas. No llegarΓ‘n correos de notificaciΓ³n hasta que se vuelva a habilitar.",
"user.settings.general.emailHelp3": "El correo electrΓ³nico es utilizado para iniciar sesiΓ³n, recibir notificaciones y para restablecer la contraseΓ±a.",
"user.settings.general.emailHelp4": "Un correo de verificaciΓ³n ha sido enviado a {email}.",
+ "user.settings.general.emailLdapCantUpdate": "El inicio de sesiΓ³n ocurre a travΓ©s LDAP. El correo electrΓ³nico no puede ser actualizado. La direcciΓ³n de correo electrΓ³nico utilizada para las notificaciones es {email}.",
"user.settings.general.emailMatch": "El nuevo correo electrΓ³nico introducido no coincide.",
+ "user.settings.general.emptyName": "Pincha 'Editar' para agregar tu nombre completo",
+ "user.settings.general.emptyNickname": "Pincha 'Edita' para agregar un sobrenombre",
"user.settings.general.firstName": "Nombre",
"user.settings.general.fullName": "Nombre completo",
"user.settings.general.imageTooLarge": "No se puede subir la imagen del perfil. El archivo es muy grande.",
"user.settings.general.imageUpdated": "Última actualizacón de la imagen {date}",
"user.settings.general.lastName": "Apellido",
- "user.settings.general.loginGitlab": "Inicio de sesiΓ³n realizado a travΓ©s de GitLab",
+ "user.settings.general.loginGitlab": "Inicio de sesiΓ³n realizado a travΓ©s de GitLab ({email})",
+ "user.settings.general.loginLdap": "Inicio de sesiΓ³n realizado a travΓ©s de LDAP ({email})",
"user.settings.general.newAddress": "Nueva direcciΓ³n: {email}<br />Revisa tu correo electrΓ³nico para verificar tu nueva direcciΓ³n.",
"user.settings.general.nickname": "Sobrenombre",
"user.settings.general.nicknameExtra": "Utiliza un Sobrenombre por el cual te conocen que sea diferente de tu nombre y del nombre de tu usuario. Esto se utiliza con mayor frecuencia cuando dos o mΓ‘s personas tienen nombres y nombres de usuario que suenan similares.",
@@ -1273,27 +1333,6 @@
"user.settings.general.usernameRestrictions": "El nombre de usuario debe empezar con una letra, y contener entre {min} a {max} caracteres en minΓΊscula con nΓΊmeros, lettras, y los sΓ­mbolos '.', '-' y '_'.",
"user.settings.general.validEmail": "Por favor ingresa una direcciΓ³n de correo electrΓ³nico vΓ‘lida",
"user.settings.general.validImage": "SΓ³lo pueden ser utilizadas imΓ‘genes JPG o PNG en el perfil",
- "user.settings.hooks_in.add": "Agregar",
- "user.settings.hooks_in.addTitle": "Agregar un nuevo webhook de entrada",
- "user.settings.hooks_in.channel": "Canal: ",
- "user.settings.hooks_in.description": "Crea URLs para webhooks a utilizar con integraciones externas. Revisa la <a href=\"http://docs.mattermost.com/developer/webhooks-incoming.html\" target=\"_blank\">documentaciΓ³n de webhooks de entrada</a> para conocer mΓ‘s. Ver todos los webhooks de entrada configurados para este equipo en la parte de abajo.",
- "user.settings.hooks_in.existing": "Webhooks de entrada existentes",
- "user.settings.hooks_in.none": "Ninguno",
- "user.settings.hooks_out.add": "Agregar",
- "user.settings.hooks_out.addDescription": "Crea webhooks para enviar mensajes a ingraciones externas. Revisa la <a href=\"http://docs.mattermost.com/developer/webhooks-outgoing.html\" target=\"_blank\">documentaciΓ³n de webhooks de saldida</a> para conocer mΓ‘s. Ver todos los webhooks de salida configurados para este equipo en la parte de abajo.",
- "user.settings.hooks_out.addTitle": "Agregar un nuevo webhook de salida",
- "user.settings.hooks_out.callback": "Callback URLs:",
- "user.settings.hooks_out.callbackDesc": "Separa por una nueva linea cada URL donde quieres recibir el evento de HTTP POST",
- "user.settings.hooks_out.callbackHolder": "Cada URL debe comenzar con http:// o https://",
- "user.settings.hooks_out.channel": "Canal: ",
- "user.settings.hooks_out.comma": "Escribe las palabras de activaciΓ³n que ejecutan el evento separadas por coma",
- "user.settings.hooks_out.existing": "Webhooks de salida existentes",
- "user.settings.hooks_out.none": "Ninguno",
- "user.settings.hooks_out.only": "SΓ³lo se pueden utilizar Canales",
- "user.settings.hooks_out.optional": "Opcional si se selecciona un canal",
- "user.settings.hooks_out.regen": "Regenerar Token",
- "user.settings.hooks_out.select": "--- Selecciona un canal ---",
- "user.settings.hooks_out.trigger": "Palabras de activaciΓ³n: ",
"user.settings.import_theme.cancel": "Cancelar",
"user.settings.import_theme.importBody": "Para importar un tema, anda al equipo Slack y busca en [Preferences -> Sidebar Theme]. Abre las opciones del tema, copia los valores de color del tema y pΓ©galo aquΓ­:",
"user.settings.import_theme.importHeader": "Importar Tema de Slack",
@@ -1301,12 +1340,15 @@
"user.settings.import_theme.submitError": "Formato invΓ‘lido, por favor intenta copiando y pegando nuevamente.",
"user.settings.integrations.commands": "Comandos de Barra",
"user.settings.integrations.commandsDescription": "Administra tus comandos de barra",
- "user.settings.integrations.incomingWebhooks": "Webhooks de entrada",
- "user.settings.integrations.incomingWebhooksDescription": "Administra tus webhooks de entrada",
- "user.settings.integrations.outWebhooks": "Webhooks de salida",
- "user.settings.integrations.outWebhooksDescription": "Administra tus webhooks de salida",
"user.settings.integrations.title": "Configuraciones de IntegraciΓ³n",
"user.settings.languages.change": "Cambia el idioma con el que se muestra la intefaz de usuario",
+ "user.settings.mfa.add": "Agrega AMF a tu cuenta",
+ "user.settings.mfa.addHelp": "Para agregar autenticaciΓ³n de mΓΊltiples factores a tu cuenta debes tener un telΓ©fono inteligente con Google Authenticator instalado.",
+ "user.settings.mfa.addHelpQr": "Por favor escanea el cΓ³digo QR con la app de Google Authenticator en tu telΓ©fono inteligente e ingresa el token provisto por la app.",
+ "user.settings.mfa.enterToken": "Token",
+ "user.settings.mfa.qrCode": "CΓ³digo QR",
+ "user.settings.mfa.remove": "Remover AMF de tu cuenta",
+ "user.settings.mfa.removeHelp": "Al remover la autenticaciΓ³n de mΓΊltples factores harΓ‘ que tu cuenta sea vulnerable a ataques.",
"user.settings.modal.advanced": "Avanzada",
"user.settings.modal.confirmBtns": "SΓ­, Descartar",
"user.settings.modal.confirmMsg": "Tienes cambios sin guardar, ΒΏEstΓ‘s seguro que los quieres descartar?",
@@ -1347,18 +1389,22 @@
"user.settings.security.emailPwd": "Correo electrΓ³nico y ContraseΓ±a",
"user.settings.security.gitlab": "GitLab SSO",
"user.settings.security.lastUpdated": "Última actualización {date} a las {time}",
+ "user.settings.security.loginGitlab": "Inicio de sesiΓ³n realizado a travΓ©s de Gitlab",
+ "user.settings.security.loginLdap": "Inicio de sesiΓ³n realizado a travΓ©s de LDAP",
"user.settings.security.logoutActiveSessions": "Visualizar y cerrar las sesiones activas",
"user.settings.security.method": "MΓ©todo de inicio de sesiΓ³n",
"user.settings.security.newPassword": "Nueva ContraseΓ±a",
"user.settings.security.oneSignin": "SΓ³lo puedes tener un mΓ©todo de inicio de sesiΓ³n a la vez. El cambio del mΓ©todo de inicio de sesiΓ³n te enviarΓ‘ un correo notificandote que el cambio se realizΓ³ con Γ©xito.",
"user.settings.security.password": "ContraseΓ±a",
+ "user.settings.security.passwordGitlabCantUpdate": "El inicio de sesiΓ³n ocurre a travΓ©s GitLab. La contraseΓ±a no se puede actualizar.",
+ "user.settings.security.passwordLdapCantUpdate": "El inicio de sesiΓ³n ocurre a travΓ©s LDAP. La contraseΓ±a no se puede actualizar.",
"user.settings.security.passwordLengthError": "La nueva contraseΓ±a debe contener al menos {chars} carΓ‘cteres",
"user.settings.security.passwordMatchError": "La nueva contraseΓ±a que ingresaste no coincide",
"user.settings.security.retypePassword": "Reescribe la Nueva ContraseΓ±a",
"user.settings.security.switchEmail": "Cambiar para utilizar correo electrΓ³nico y contraseΓ±a",
"user.settings.security.switchGitlab": "Cambiar para utilizar GitLab SSO",
"user.settings.security.switchGoogle": "Cambiar para utilizar Google SSO",
- "user.settings.security.switchLda": "Cambiar a utilizar LDAP",
+ "user.settings.security.switchLdap": "Cambiar a utilizar LDAP",
"user.settings.security.title": "ConfiguraciΓ³n de Seguridad",
"user.settings.security.viewHistory": "Visualizar historial de acceso",
"user_list.notFound": "No se encontraron usuarios :(",
diff --git a/webapp/i18n/fr.json b/webapp/i18n/fr.json
index 3270b8847..be207f0da 100644
--- a/webapp/i18n/fr.json
+++ b/webapp/i18n/fr.json
@@ -986,7 +986,7 @@
"sidebar.pg": "Groupes privΓ©s",
"sidebar.removeList": "Retirer de la liste",
"sidebar.tutorialScreen1": "<h4>Canaux</h4><p><strong>Les canaux</strong> organisent les conversations en sujets distincts. Ils sont ouverts Γ  tout le monde dans votre Γ©quipe. Pour envoyer des messages privΓ©s, utilisez <strong>Messages PrivΓ©s</strong> pour une personne ou <strong>Groupes PrivΓ©s</strong> pour plusieurs personnes.</p>",
- "sidebar.tutorialScreen2": "<h4>Canaux \"Town Square\" et \"Off-Topic\"</h4><p>Voici deux canaux publics pour commencer :</p><p><strong>Town Square</strong> (\"centre-ville\") est l'endroit idΓ©al pour communiquer avec toute l'Γ©quipe. Tous les membres de votre Γ©quipe sont membres de ce canal.</p><p><strong>Off-Topic</strong> (\"hors-sujet\") est l'endroit pour se dΓ©tendre et parler d'autre chose que de travail. Vous et votre Γ©quipe dΓ©cidez des autres canaux Γ  crΓ©er.</p>",
+ "sidebar.tutorialScreen2": "<h4>Canaux \"{townsquare}\" et \"{offtopic}\"</h4><p>Voici deux canaux publics pour commencer :</p><p><strong>{townsquare}</strong> (\"centre-ville\") est l'endroit idΓ©al pour communiquer avec toute l'Γ©quipe. Tous les membres de votre Γ©quipe sont membres de ce canal.</p><p><strong>{offtopic}</strong> (\"hors-sujet\") est l'endroit pour se dΓ©tendre et parler d'autre chose que de travail. Vous et votre Γ©quipe dΓ©cidez des autres canaux Γ  crΓ©er.</p>",
"sidebar.tutorialScreen3": "<h4>CrΓ©er et rejoindre des canaux</h4><p>Cliquez sur <strong>\"Plus...\"</strong> pour crΓ©er un nouveau canal ou rejoindre un canal existant.</p><p>Vous pouvez aussi crΓ©er un nouveau canal ou un groupe privΓ© en cliquant sur le symbole <strong>\"+\"</strong> Γ  cΓ΄tΓ© du nom du canal ou de l'en-tΓͺte du groupe privΓ©.</p>",
"sidebar.unreadAbove": "Message(s) non-lu(s) ci-dessus",
"sidebar.unreadBelow": "Message(s) non-lu(s) ci-dessous",
@@ -1134,7 +1134,7 @@
"textbox.quote": ">citation",
"textbox.strike": "barrΓ©",
"tutorial_intro.allSet": "C'est parti !",
- "tutorial_intro.end": "Cliquez sur \"Suivant\" pour entrer dans Town Square. C'est le premier canal que les membres voient quand ils s'inscrivent. Utilisez-le pour poster des messages que tout le monde doit lire en premier.",
+ "tutorial_intro.end": "Cliquez sur \"Suivant\" pour entrer dans {channel}. C'est le premier canal que les membres voient quand ils s'inscrivent. Utilisez-le pour poster des messages que tout le monde doit lire en premier.",
"tutorial_intro.invite": "Inviter des membres",
"tutorial_intro.next": "Suivant",
"tutorial_intro.screenOne": "<h3>Bienvenue sur :</h3><h1>Mattermost</h1><p>Toute la communication de votre Γ©quipe Γ  un seul endroit, Your team communication all in one place, instantanΓ©ment consultable et disponible partout.</p><p>Gardez le lien avec votre Γ©quipe pour accomplir les tΓ’ches les plus importantes.</p>",
@@ -1247,7 +1247,6 @@
"user.settings.general.close": "Quitter",
"user.settings.general.confirmEmail": "Courriel de confirmation",
"user.settings.general.email": "Adresse Γ©lectronique",
- "user.settings.general.emailCantUpdate": "La connexion s'effectue par GitLab. L'adresse Γ©lectronique ne peut Γͺtre modifiΓ©e.",
"user.settings.general.emailHelp1": "L'adresse Γ©lectronique est utilisΓ© pour la connexion, les notifications et la rΓ©initialisation du mot de passe. Votre adresse Γ©lectronique doit Γͺtre validΓ© si vous le changez.",
"user.settings.general.emailHelp2": "Les courriels sont dΓ©sactivΓ©s par votre administrateur systΓ¨me. Aucune notification ne peut Γͺtre envoyΓ©e.",
"user.settings.general.emailHelp3": "L'adresse Γ©lectronique est utilisΓ©e pour la connexion, les notifications et la rΓ©initialisation du mot de passe.",
@@ -1258,7 +1257,7 @@
"user.settings.general.imageTooLarge": "Impossible de mettre Γ  jour votre photo de profil. Le fichier est trop grand.",
"user.settings.general.imageUpdated": "Image mise Γ  jour le {date}",
"user.settings.general.lastName": "Nom",
- "user.settings.general.loginGitlab": "Connexion avec GitLab",
+ "user.settings.general.loginGitlab": "Connexion avec GitLab ({email})",
"user.settings.general.newAddress": "Nouvelle adresse : {email}<br />VΓ©rifiez votre messagerie pour valider votre adresse Γ©lectronique.",
"user.settings.general.nickname": "Pseudo",
"user.settings.general.nicknameExtra": "Vous pouvez utiliser un pseudo Γ  la place de vos prΓ©nom, nom et nom d'utilisateur. Ceci est pratique lorsque deux personnes de votre Γ©quipe ont des noms proches.",
@@ -1274,27 +1273,6 @@
"user.settings.general.usernameRestrictions": "Les noms d'utilisateurs doivent commencer par une lettre et contenir entre {min} et {max} caractères composés de chiffres, lettres minuscules et des symboles '.', '-' et '_'",
"user.settings.general.validEmail": "Veuillez entrer une adresse Γ©lectronique valide",
"user.settings.general.validImage": "Seules les images JPG ou PNG sont autorisΓ©es pour les photos de profil",
- "user.settings.hooks_in.add": "Ajouter",
- "user.settings.hooks_in.addTitle": "Ajouter un webhook entrant",
- "user.settings.hooks_in.channel": "Canal\u00a0: ",
- "user.settings.hooks_in.description": "Crééez des URLs de webhooks pour des intégrations externes. Veuillez consulter <a href=\"http://docs.mattermost.com/developer/webhooks-incoming.html\" target=\"_blank\">la documentation sur les webhooks entrants</a> pour en savoir plus. Examinez tous les webhooks entrants configurés pour cette équipe ci-dessous.",
- "user.settings.hooks_in.existing": "Webhooks entrants",
- "user.settings.hooks_in.none": "Aucun",
- "user.settings.hooks_out.add": "Ajouter",
- "user.settings.hooks_out.addDescription": "Crééez des webhooks pour envoyer les évènements de nouveaux messages vers des intégrations externes. Veuillez consulter <a href=\"http://docs.mattermost.com/developer/webhooks-outgoing.html\" target=\"_blank\">la documentation sur les webhooks sortants</a> pour en savoir plus. Examinez tous les webhooks sortants configurés pour cette équipe ci-dessous.",
- "user.settings.hooks_out.addTitle": "Ajouter un webhook sortant",
- "user.settings.hooks_out.callback": "URLs de callback :",
- "user.settings.hooks_out.callbackDesc": "URLs sΓ©parΓ©s par un saut de ligne qui recevront l'Γ©vΓ©nement HTTP POST",
- "user.settings.hooks_out.callbackHolder": "Chaque URL doit commencer par http:// ou https://",
- "user.settings.hooks_out.channel": "Canal\u00a0: ",
- "user.settings.hooks_out.comma": "Liste de mots dΓ©clencheurs sΓ©parΓ©s par une virgule",
- "user.settings.hooks_out.existing": "Webhooks sortants",
- "user.settings.hooks_out.none": "Aucun",
- "user.settings.hooks_out.only": "Seuls les canaux publics peuvent Γͺtre utilisΓ©s",
- "user.settings.hooks_out.optional": "Facultatif si un canal est sΓ©lectionnΓ©",
- "user.settings.hooks_out.regen": "RΓ©initialiser le jeton",
- "user.settings.hooks_out.select": "--- Choisissez un canal ---",
- "user.settings.hooks_out.trigger": "Mots de dΓ©clenchement :",
"user.settings.import_theme.cancel": "Annuler",
"user.settings.import_theme.importBody": "Pour importer un thΓ¨me, rendez-vous sur une Slack team et cliquez sur \"Preferences -> Sidebar Theme\". Ouvrez la fenΓͺtre de personnalisation, copiez les couleurs du thΓ¨mes et collez-les ici :",
"user.settings.import_theme.importHeader": "Importer un thème Slack",
@@ -1302,10 +1280,6 @@
"user.settings.import_theme.submitError": "Format invalide, veuillez rΓ©essayer de copier-coller.",
"user.settings.integrations.commands": "Commandes slash",
"user.settings.integrations.commandsDescription": "GΓ©rez vos commandes slash",
- "user.settings.integrations.incomingWebhooks": "Webhooks entrants",
- "user.settings.integrations.incomingWebhooksDescription": "GΓ©rer les webhooks entrants",
- "user.settings.integrations.outWebhooks": "Webhooks sortants",
- "user.settings.integrations.outWebhooksDescription": "GΓ©rer les webhooks sortants",
"user.settings.integrations.title": "Paramètres d'intégration",
"user.settings.languages.change": "Changer la langue de l'interface",
"user.settings.modal.advanced": "Options avancΓ©es",
@@ -1374,4 +1348,4 @@
"web.footer.terms": "Termes",
"web.header.back": "PrΓ©cΓ©dent",
"web.root.singup_info": "Toute la communication de votre Γ©quipe Γ  un endroit, accessible de partout"
-} \ No newline at end of file
+}
diff --git a/webapp/i18n/pt.json b/webapp/i18n/pt.json
index 0b06b77af..7525306e6 100644
--- a/webapp/i18n/pt.json
+++ b/webapp/i18n/pt.json
@@ -22,6 +22,28 @@
"activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "App Nativo Android",
"activity_log_modal.iphoneNativeApp": "App Nativo para iPhone",
+ "add_incoming_webhook.cancel": "Cancelar",
+ "add_incoming_webhook.channel": "Canal",
+ "add_incoming_webhook.channelRequired": "Um canal vΓ‘lido Γ© necessΓ‘rio",
+ "add_incoming_webhook.description": "Descrição",
+ "add_incoming_webhook.header": "Adicionar Webhooks Entrada",
+ "add_incoming_webhook.name": "Nome",
+ "add_incoming_webhook.save": "Salvar",
+ "add_integration.header": "Adicionar Integração",
+ "add_integration.incomingWebhook.title": "Webhooks Entrada",
+ "add_integration.incomingWebhook.description": "Criar URLs webhook para usar em integraçáes externas",
+ "add_integration.outgoingWebhook.title": "Webhooks SaΓ­da",
+ "add_integration.outgoingWebhook.description": "Criar webhook para enviar novos eventos de mensagens para uma integração externa.",
+ "add_outgoing_webhook.callbackUrls": "URLs Callback (Uma Por Linha)",
+ "add_outgoing_webhook.callbackUrlsRequired": "Uma ou mais URLs callback sΓ£o necessΓ‘rias",
+ "add_outgoing_webhook.cancel": "Cancelar",
+ "add_outgoing_webhook.channel": "Canal",
+ "add_outgoing_webhook.description": "Descrição",
+ "add_outgoing_webhook.header": "Adicionar Webhooks SaΓ­da",
+ "add_outgoing_webhook.name": "Nome",
+ "add_outgoing_webhook.save": "Salvar",
+ "add_outgoing_webhook.triggerWOrds": "Palavras Gatilho (Uma Por Linha)",
+ "add_outgoing_webhook.triggerWordsOrChannelRequired": "Um canal vΓ‘lido ou uma lista de palavras gatilho Γ© necessΓ‘rio",
"admin.audits.reload": "Recarregar",
"admin.audits.title": "Atividade de UsuΓ‘rio",
"admin.compliance.directoryDescription": "DiretΓ³rio o qual os relatΓ³rios compliance sΓ£o gravados, Se estiver em branco, serΓ‘ usado ./data/.",
@@ -230,15 +252,9 @@
"admin.ldap.usernameAttrTitle": "Atributo do UsuΓ‘rio:",
"admin.licence.keyMigration": "Se vocΓͺ estiver migrando seu servidor vocΓͺ pode precisar remover sua chave da licenΓ§a deste servidor a pedido para instala-la em um novo servidor. Para iniciar, <a href=\"http://mattermost.com\" target=\"_blank\">desativar todos os recursos Enterprise Edition deste servidor</a>. Isto irΓ‘ habilitar para remover a chave da licenΓ§a e fazer downgrade deste servidor Enterprise Edition para Team Edition.",
"admin.license.chooseFile": "Escolha um Arquivo",
- "admin.license.edition": "Edição: ",
- "admin.license.enterpriseEdition": "Mattermost Enterprise Edition. Desenvolvido para escala empresarial de comunicação.",
- "admin.license.enterpriseType": "<div><p>Esta versão compilada da plataforma Mattermost é fornecida sob a <a href=\"http://mattermost.com\" target=\"_blank\">licença comercial</a> para Mattermost, Inc. com base em seu nível de subscrição e estÑ sujeito a <a href=\"{terms}\" target=\"_blank\">Termos de Serviço.</a></p><p>Os detalhes de sua assinatura, são como segue:</p>Nome: {name}<br />Nome da Empresa ou organização: {company}<br/>Número de usuÑrios: {users}<br/>Licença emitida: {issued}<br/>Data de Início da licença: {start}<br/>Data de expiração da licença: {expires}<br/>LDAP: {ldap}<br/></div>",
- "admin.license.key": "Chave da Licença: ",
"admin.license.keyRemove": "Remover a Licença Enterprise e fazer Downgrade do Servidor",
"admin.license.noFile": "Nenhum arquivo enviado",
"admin.license.removing": "Removendo a Licença...",
- "admin.license.teamEdition": "Mattermost Team Edition. Desenvolvido para equipes de 5 a 50 usuΓ‘rios.",
- "admin.license.teamType": "<span><p>Esta versão compilada da plataforma Mattermost é oferecido sob uma licença MIT.</p><p>Ver MIT-COMPILED-LICENSE.txt no raiz do diretório de instalação para obter detalhes. Ver NOTICES.txt para obter informaçáes sobre o software open source usados neste sistema.</p></span>",
"admin.license.title": "Edição e Licença",
"admin.license.type": "Licença: ",
"admin.license.upload": "Enviar",
@@ -312,6 +328,8 @@
"admin.select_team.close": "Fechar",
"admin.select_team.select": "Selecionar",
"admin.select_team.selectTeam": "Selecione Equipe",
+ "admin.service.mfaTitle": "Ativar Autenticação Multi-Fator:",
+ "admin.service.mfaDesc": "Quando verdadeiro, vai ser dada a opção do usuÑrio adicionar autenticação multi-fator em sua conta. Eles irão precisar de um smartphone e um app autenticador como o Google Authenticator.",
"admin.service.attemptDescription": "Tentativas de login permitidas antes que do usuΓ‘rio ser bloqueado e necessΓ‘rio redefinir a senha por e-mail.",
"admin.service.attemptExample": "Ex \"10\"",
"admin.service.attemptTitle": "MΓ‘xima Tentativas de Login:",
@@ -416,6 +434,8 @@
"admin.support.emailTitle": "E-mail de suporte:",
"admin.support.helpDesc": "Link para documentação de ajuda para o site da equipe no menu principal. Normalmente não é alterado ao menos se sua empresa escolha criar uma documentação customizada.",
"admin.support.helpTitle": "Link de ajuda:",
+ "admin.support.noteDescription": "Se links para um site externo, URLs devem começar com http:// ou https://.",
+ "admin.support.noteTitle": "Nota:",
"admin.support.privacyDesc": "Link para Política de Privacidade para os usuÑrios no desktop ou móvel. Deixando este espaço em branco irÑ esconder a opção de exibir um aviso.",
"admin.support.privacyTitle": "Link da PolΓ­tica de Privacidade:",
"admin.support.problemDesc": "Link para a documentação de ajuda do site no menu principal. Por padrão este aponta para um fórum peer-to-peer de solução de problemas onde os usuÑrios podem pesquisar, encontrar e pedir ajuda com problemas técnicos.",
@@ -425,8 +445,6 @@
"admin.support.termsDesc": "Link para os Termos de Serviço para os usuÑrios no desktop ou móvel. Deixando este espaço em branco irÑ esconder a opção de exibir um aviso.",
"admin.support.termsTitle": "Link Termos do Serviço:",
"admin.support.title": "Configuraçáes jurídico e apoio",
- "admin.support.noteTitle": "Note:",
- "admin.support.noteDescription": "If linking to an external site, URLs should begin with http:// or https://.",
"admin.system_analytics.activeUsers": "UsuΓ‘rios Ativos com Postagens",
"admin.system_analytics.title": "o Sistema",
"admin.system_analytics.totalPosts": "Total Posts",
@@ -556,6 +574,12 @@
"authorize.app": "O app <strong>{appName}</strong> gostaria de ter a capacidade de acessar e modificar suas informaçáes bÑsicas.",
"authorize.deny": "Negar",
"authorize.title": "Um aplicativo gostaria de conectar na sua conta {teamName}",
+ "backstage_navbar.backToMattermost": "Voltar para {siteName}",
+ "backstage_sidebar.integrations": "Integraçáes",
+ "backstage_sidebar.integrations.installed": "Integraçáes Instaladas",
+ "backstage_sidebar.integrations.add": "Adicionar Integração",
+ "backstage_sidebar.integrations.add.incomingWebhook": "Webhooks Entrada",
+ "backstage_sidebar.integrations.add.outgoingWebhook": "Webhooks SaΓ­da",
"center_panel.recent": "Clique aqui para pular para mensagens recentes. ",
"chanel_header.addMembers": "Adicionar Membros",
"change_url.close": "Fechar",
@@ -632,6 +656,7 @@
"channel_notifications.preferences": "PreferΓͺncias de Notificação para ",
"channel_notifications.sendDesktop": "Enviar notificaçáes de desktop",
"channel_notifications.unreadInfo": "O nome do canal fica em negrito na barra lateral quando houver mensagens nΓ£o lidas. Selecionando \"Apenas mençáes\" o canal vai ficar em negrito apenas quando vocΓͺ for mencionado.",
+ "channel_select.placeholder": "--- Selecione um canal ---",
"choose_auth_page.emailCreate": "Criar uma nova equipe com endereço de email",
"choose_auth_page.find": "Encontrar minhas equipes",
"choose_auth_page.gitlabCreate": "Criar uma equipe com uma conta GitLab",
@@ -639,6 +664,18 @@
"choose_auth_page.ldapCreate": "Criar uma nova equipe com uma conta LDAP",
"choose_auth_page.noSignup": "Nenhum método de inscrição configurado, por favor contate seu administrador do sistema.",
"claim.account.noEmail": "Nenhum email especΓ­ficado",
+ "claim.email_to_ldap.enterLdapPwd": "Entre o ID e a senha para sua conta LDAP",
+ "claim.email_to_ldap.enterPwd": "Entre a senha para o sua conta com email {team} {site}",
+ "claim.email_to_ldap.ldapId": "LDAP ID",
+ "claim.email_to_ldap.ldapIdError": "Por favor digite seu ID LDAP.",
+ "claim.email_to_ldap.ldapPasswordError": "Por favor digite a sua senha LDAP.",
+ "claim.email_to_ldap.ldapPwd": "Senha LDAP",
+ "claim.email_to_ldap.pwd": "Senha",
+ "claim.email_to_ldap.pwdError": "Por favor digite a sua senha.",
+ "claim.email_to_ldap.ssoNote": "VocΓͺ precisa jΓ‘ ter uma conta LDAP vΓ‘lida",
+ "claim.email_to_ldap.ssoType": "Ao retirar a sua conta, vocΓͺ sΓ³ vai ser capaz de logar com LDAP",
+ "claim.email_to_ldap.switchTo": "Trocar a conta para LDAP",
+ "claim.email_to_ldap.title": "Trocar E-mail/Senha da Conta para LDAP",
"claim.email_to_oauth.enterPwd": "Entre a senha para o sua conta {team} {site}",
"claim.email_to_oauth.pwd": "Senha",
"claim.email_to_oauth.pwdError": "Por favor digite a sua senha.",
@@ -646,14 +683,25 @@
"claim.email_to_oauth.ssoType": "Ao retirar a sua conta, vocΓͺ sΓ³ vai ser capaz de logar com SSO {type}",
"claim.email_to_oauth.switchTo": "Trocar a conta para {uiType}",
"claim.email_to_oauth.title": "Trocar E-mail/Senha da Conta para {uiType}",
- "claim.oauth_to_email.confirm": "Confirmar senha",
+ "claim.ldap_to_email.confirm": "Confirmar senha",
+ "claim.ldap_to_email.email": "VocΓͺ vai usar o email {email} para logar",
+ "claim.ldap_to_email.enterLdapPwd": "Entre a sua senha LDAP para o sua conta {team} {site}",
+ "claim.ldap_to_email.enterPwd": "Entre a nova senha para o sua conta com email.",
+ "claim.ldap_to_email.ldapPasswordError": "Por favor digite a sua senha LDAP.",
+ "claim.ldap_to_email.ldapPwd": "Senha LDAP",
+ "claim.ldap_to_email.pwd": "Senha",
+ "claim.ldap_to_email.pwdError": "Por favor digite a sua senha.",
+ "claim.ldap_to_email.pwdNotMatch": "As senha nΓ£o correspondem.",
+ "claim.ldap_to_email.ssoType": "ApΓ³s a alteração do tipo de conta, vocΓͺ sΓ³ vai ser capaz de logar com seu e-mail e senha.",
+ "claim.ldap_to_email.switchTo": "Trocar a conta para e-mail/senha",
+ "claim.ldap_to_email.title": "Trocar a conta LDAP para E-mail/Senha",
+ "claim.oauth_to_email.confirm": "Confirmar Senha",
"claim.oauth_to_email.description": "ApΓ³s a alteração do tipo de conta, vocΓͺ sΓ³ vai ser capaz de logar com seu e-mail e senha.",
"claim.oauth_to_email.enterPwd": "Por favor entre uma senha.",
"claim.oauth_to_email.newPwd": "Nova Senha",
"claim.oauth_to_email.pwdNotMatch": "As senha nΓ£o correspondem.",
"claim.oauth_to_email.switchTo": "Trocar {type} para email e senha",
"claim.oauth_to_email.title": "Trocar Conta {type} para E-mail",
- "claim.oauth_to_email.newPwd": "Entre a nova senha para o sua conta {team} {site}",
"confirm_modal.cancel": "Cancelar",
"create_comment.addComment": "Adicionar um comentΓ‘rio...",
"create_comment.comment": "Adicionar ComentΓ‘rio",
@@ -751,6 +799,16 @@
"get_team_invite_link_modal.help": "Enviar o link abaixo para sua equipe de trabalho para que eles se inscrevam no site da sua equipe. O Link de Convite de Equipe como ele não muda pode ser compartilhado com vÑrias pessoas ao menos que seja re-gerado em Configuraçáes de Equipe pelo Administrador de Equipe.",
"get_team_invite_link_modal.helpDisabled": "Criação de usuÑrios estÑ desabilitada para sua equipe. Por favor peça ao administrador de equipe por detalhes.",
"get_team_invite_link_modal.title": "Link para Convite de Equipe",
+ "installed_integrations.add": "Adicionar Integração",
+ "installed_integrations.allFilter": "Todos",
+ "installed_integrations.delete": "Deletar",
+ "installed_integrations.header": "Integraçáes Instaladas",
+ "installed_integrations.incomingWebhooksFilter": "Webhooks Entrada ({count})",
+ "installed_integrations.incomingWebhookType": "(Webhooks Entrada)",
+ "installed_integrations.outgoingWebhooksFilter": "Webhooks SaΓ­da ({count})",
+ "installed_integrations.outgoingWebhookType": "(Webhooks SaΓ­da)",
+ "installed_integrations.regenToken": "Regen Token",
+ "installed_integrations.search": "Pesquisar Integraçáes",
"intro_messages.DM": "Este Γ© o inΓ­cio do seu histΓ³rico de mensagens diretas com {teammate}.<br />Mensagens diretas e arquivos compartilhados aqui nΓ£o sΓ£o mostrados para pessoas de fora desta Γ‘rea.",
"intro_messages.anyMember": " Qualquer membro pode participar e ler este canal.",
"intro_messages.beginning": "InΓ­cio do {name}",
@@ -801,6 +859,10 @@
"login.session_expired": " Sua sessão expirou. Por favor faça login novamente.",
"login.signTo": "Login em:",
"login.verified": " Email Verificado",
+ "login_mfa.token": "Token MFA",
+ "login_mfa.enterToken": "Para completar o login em processo, por favor entre um token do seu autenticador no smartphone",
+ "login_mfa.submit": "Enviar",
+ "login_mfa.tokenReq": "Por favor entre um token MFA",
"login_email.badTeam": "Nome ruim de equipe",
"login_email.email": "E-mail",
"login_email.emailReq": "Um email Γ© necessΓ‘rio",
@@ -856,6 +918,7 @@
"navbar_dropdown.console": "Console do Sistema",
"navbar_dropdown.create": "Criar uma Nova Equipe",
"navbar_dropdown.help": "Ajuda",
+ "navbar_dropdown.integrations": "Integraçáes",
"navbar_dropdown.inviteMember": "Convidar Membros da Equipe",
"navbar_dropdown.logout": "Logout",
"navbar_dropdown.manageMembers": "Gerenciar Membros",
@@ -968,7 +1031,7 @@
"sidebar.pg": "Grupos Privados",
"sidebar.removeList": "Remover da lista",
"sidebar.tutorialScreen1": "<h4>Canais</h4><p><strong>Canais</strong> organizar conversas em diferentes tópicos. Eles estão abertos a todos em sua equipe. Para enviar comunicaçáes privadas utilize <strong>Mensagens Diretas</strong> para uma única pessoa ou <strong>Grupos Privados</strong> para vÑrias pessoas.</p>",
- "sidebar.tutorialScreen2": "<h4>Canais \"Town Square\" e \"Off-Topic\"</h4><p>Aqui estΓ£o dois canais pΓΊblicos para comeΓ§ar:</p><p><strong>Town Square</strong> Γ© um lugar comunicação de toda equipe. Todo mundo em sua equipe Γ© um membro deste canal.</p><p><strong>Off-Topic</strong> Γ© um lugar para diversΓ£o e humor fora dos canais relacionados com o trabalho. VocΓͺ e sua equipe podem decidir qual outros canais serΓ£o criados.</p>",
+ "sidebar.tutorialScreen2": "<h4>Canais \"{townsquare}\" e \"{offtopic}\"</h4><p>Aqui estΓ£o dois canais pΓΊblicos para comeΓ§ar:</p><p><strong>{townsquare}</strong> Γ© um lugar comunicação de toda equipe. Todo mundo em sua equipe Γ© um membro deste canal.</p><p><strong>{offtopic}</strong> Γ© um lugar para diversΓ£o e humor fora dos canais relacionados com o trabalho. VocΓͺ e sua equipe podem decidir qual outros canais serΓ£o criados.</p>",
"sidebar.tutorialScreen3": "<h4>Criando e participando de Canais</h4><p>Clique em <strong>\"Mais...\"</strong> para criar um novo canal ou participar de um jΓ‘ existente.</p><p>VocΓͺ tambΓ©m pode criar um novo canal ou grupo privado ao clicar em <strong>no sΓ­mbolo \"+\"</strong> ao lado do canal ou grupo privado no cabeΓ§alho.</p>",
"sidebar.unreadAbove": "Post(s) nΓ£o lidos abaixo",
"sidebar.unreadBelow": "Post(s) nΓ£o lidos abaixo",
@@ -1116,7 +1179,7 @@
"textbox.quote": ">citar",
"textbox.strike": "tachado",
"tutorial_intro.allSet": "EstΓ‘ tudo pronto",
- "tutorial_intro.end": "Clique em β€œPrΓ³ximo” para entrar Town Square. Este Γ© o primeiro canal que sua equipe de trabalho vΓͺ quando eles se inscrevem. Use para postar atualizaçáes que todos precisam saber.",
+ "tutorial_intro.end": "Clique em β€œPrΓ³ximo” para entrar {channel}. Este Γ© o primeiro canal que sua equipe de trabalho vΓͺ quando eles se inscrevem. Use para postar atualizaçáes que todos precisam saber.",
"tutorial_intro.invite": "Convidar pessoas para equipe",
"tutorial_intro.next": "PrΓ³ximo",
"tutorial_intro.screenOne": "<h3>Bem vindo ao:</h3><h1>Mattermost</h1><p>Sua equipe de comunicação em um só lugar, pesquisas instantÒneas disponível em qualquer lugar</p><p>Mantenha sua equipe conectada para ajudÑ-los a conseguir o que mais importa.</p>",
@@ -1221,6 +1284,7 @@
"user.settings.display.theme.customTheme": "Tema Customizado",
"user.settings.display.theme.describe": "Abrir para gerenciar seu tema",
"user.settings.display.theme.import": "Importar tema de cores do Slack",
+ "user.settings.display.theme.otherThemes": "Veja outros temas",
"user.settings.display.theme.themeColors": "Tema de Cores",
"user.settings.display.theme.title": "Tema",
"user.settings.display.title": "Configuraçáes de Exibição",
@@ -1229,18 +1293,22 @@
"user.settings.general.close": "Fechar",
"user.settings.general.confirmEmail": "Confirmar o email",
"user.settings.general.email": "E-mail",
- "user.settings.general.emailCantUpdate": "Login ocorreu atravΓ©s do GitLab. Email nΓ£o pode ser atualizado.",
+ "user.settings.general.emailGitlabCantUpdate": "Login ocorre através do GitLab. Email não pode ser atualizado. Endereço de email utilizado para notificaçáes é {email}.",
+ "user.settings.general.emailLdapCantUpdate": "Login ocorre através de LDAP. Email não pode ser atualizado. Endereço de email utilizado para notificaçáes é {email}.",
"user.settings.general.emailHelp1": "Email é usado para login, notificaçáes, e redefinição de senha. Requer verificação de email se alterado.",
"user.settings.general.emailHelp2": "Email foi desativado pelo seu administrador de sistema. Nenhuma notificação por email serÑ enviada até isto ser habilitado.",
"user.settings.general.emailHelp3": "Email é usado para login, notificaçáes e redefinição de senha.",
"user.settings.general.emailHelp4": "Uma verificação por email foi enviada para {email}.",
"user.settings.general.emailMatch": "Os novos emails que vocΓͺ inseriu nΓ£o correspondem.",
+ "user.settings.general.emptyName": "Clique 'Editar' para adicionar seu nome completo",
+ "user.settings.general.emptyNickname": "Clique 'Editar' para adicionar um apelido",
"user.settings.general.firstName": "Primeiro nome",
"user.settings.general.fullName": "Nome Completo",
"user.settings.general.imageTooLarge": "NΓ£o Γ© possΓ­vel fazer upload da imagem de perfil. O arquivo Γ© muito grande.",
"user.settings.general.imageUpdated": "Imagem última atualização {date}",
"user.settings.general.lastName": "Último Nome",
- "user.settings.general.loginGitlab": "Login feito atravΓ©s do GitLab",
+ "user.settings.general.loginGitlab": "Login feito atravΓ©s do GitLab ({email})",
+ "user.settings.general.loginLdap": "Login feito atravΓ©s de LDAP ({email})",
"user.settings.general.newAddress": "Novo Endereço: {email}<br />Verifique seu email para checar o endereço acima.",
"user.settings.general.nickname": "Apelido",
"user.settings.general.nicknameExtra": "Use Apelidos para um nome vocΓͺ pode ser chamado assim, isso Γ© diferente de seu primeiro nome e nome de usuΓ‘rio. Este Γ© mais frequentemente usado quando duas ou mais pessoas tΓͺm nomes semelhantes de usuΓ‘rio.",
@@ -1256,27 +1324,6 @@
"user.settings.general.usernameRestrictions": "O nome de usuÑrio precisa começar com uma letra, e conter entre {min} e {max} caracteres minúsculos contendo números, letras, e os símbolos '.', '-' e '_'.",
"user.settings.general.validEmail": "Por favor entre um endereço de e-mail vÑlido",
"user.settings.general.validImage": "Somente imagens em JPG ou PNG podem ser usadas como imagem do perfil",
- "user.settings.hooks_in.add": "Adicionar",
- "user.settings.hooks_in.addTitle": "Adicionar um novo webhook entrada",
- "user.settings.hooks_in.channel": "Canal: ",
- "user.settings.hooks_in.description": "Criar URLs webhook para usar em integraçáes externas. Por favor veja <a href=\"http://docs.mattermost.com/developer/webhooks-incoming.html\" target=\"_blank\">documentação webhook entrada</a> para saber mais. Ver todos os webhooks de entrada configurados nesta equipe abaixo.",
- "user.settings.hooks_in.existing": "Webhooks de entrada existentes",
- "user.settings.hooks_in.none": "Nenhum",
- "user.settings.hooks_out.add": "Adicionar",
- "user.settings.hooks_out.addDescription": "Criar webhooks para enviar novos mensagens de eventos para uma integração externa. Por favor veja <a href=\"http://docs.mattermost.com/developer/webhooks-outgoing.html\" target=\"_blank\">documentação webhook saída</a> para saber mais. Ver todos os webhooks de saída desta equipe configurados abaixo.",
- "user.settings.hooks_out.addTitle": "Adicionar um novo webhook saΓ­da",
- "user.settings.hooks_out.callback": "Callback URLs: ",
- "user.settings.hooks_out.callbackDesc": "Nova linha separada de URLs que receberΓ‘ o evento HTTP POST",
- "user.settings.hooks_out.callbackHolder": "Cada URL deve começar com http:// ou https://",
- "user.settings.hooks_out.channel": "Canal: ",
- "user.settings.hooks_out.comma": "Palavras separadas por virgula para gatilho em",
- "user.settings.hooks_out.existing": "Webhooks de saΓ­da existentes",
- "user.settings.hooks_out.none": "Nenhum",
- "user.settings.hooks_out.only": "Apenas canais pΓΊblicos pode ser usado",
- "user.settings.hooks_out.optional": "Opcional se o canal selecionado",
- "user.settings.hooks_out.regen": "Re-Gerar Token",
- "user.settings.hooks_out.select": "--- Selecione um canal ---",
- "user.settings.hooks_out.trigger": "Palavras de Gatilho: ",
"user.settings.import_theme.cancel": "Cancelar",
"user.settings.import_theme.importBody": "Para importar um tema, vΓ‘ para uma equipe no Slack e olhe para β€œPreferences -> Sidebar Theme”. Abra a opção de tema customizado, copie os valores das cores do tema e cole eles aqui:",
"user.settings.import_theme.importHeader": "Importar Tema Slack",
@@ -1284,10 +1331,6 @@
"user.settings.import_theme.submitError": "Formato invΓ‘lido, por favor tente copiar e colar novamente.",
"user.settings.integrations.commands": "Comandos Slash",
"user.settings.integrations.commandsDescription": "Gerenciar seus comandos slash",
- "user.settings.integrations.incomingWebhooks": "Webhooks Entrada",
- "user.settings.integrations.incomingWebhooksDescription": "Gerencie seus webhooks entrada",
- "user.settings.integrations.outWebhooks": "Webhooks SaΓ­da",
- "user.settings.integrations.outWebhooksDescription": "Gerencie seus webhooks saΓ­da",
"user.settings.integrations.title": "Configuração de Integração",
"user.settings.languages.change": "Alterar o idioma da interface",
"user.settings.modal.advanced": "Avançado",
@@ -1330,17 +1373,22 @@
"user.settings.security.emailPwd": "Email e Senha",
"user.settings.security.gitlab": "GitLab SSO",
"user.settings.security.lastUpdated": "Última atualização {date} {time}",
+ "user.settings.security.loginGitlab": "Login feito atravΓ©s do GitLab",
+ "user.settings.security.loginLdap": "Login feito atravΓ©s de LDAP",
"user.settings.security.logoutActiveSessions": "Ver e fazer Logout das SessΓ΅es Ativas",
"user.settings.security.method": "MΓ©todo de Login",
"user.settings.security.newPassword": "Nova Senha",
"user.settings.security.oneSignin": "VocΓͺ pode ter somente um mΓ©todo de login por vez. Trocando o mΓ©todo de login serΓ‘ enviado um email de notificação se vocΓͺ alterar com sucesso.",
"user.settings.security.password": "Senha",
+ "user.settings.security.passwordGitlabCantUpdate": "Login ocorreu atravΓ©s do GitLab. Senha nΓ£o pode ser atualizada.",
+ "user.settings.security.passwordLdapCantUpdate": "Login ocorreu atravΓ©s de LDAP. Senha nΓ£o pode ser atualizada.",
"user.settings.security.passwordLengthError": "Novas senhas precisam ter pelo menos {chars} characters",
"user.settings.security.passwordMatchError": "As novas senhas que vocΓͺ inseriu nΓ£o correspondem",
"user.settings.security.retypePassword": "Digite Novamente a nova Senha",
"user.settings.security.switchEmail": "Trocar para usar email e senha",
"user.settings.security.switchGitlab": "Trocar para usar GitLab SSO",
"user.settings.security.switchGoogle": "Trocar para usar Google SSO",
+ "user.settings.security.switchLda": "Trocar para usar LDAP",
"user.settings.security.title": "Configuraçáes de Segurança",
"user.settings.security.viewHistory": "Ver HistΓ³rico de Acesso",
"user_list.notFound": "Nenhum usuΓ‘rio encontrado :(",
diff --git a/webapp/images/emoji/1f1e8-1f1e6.png b/webapp/images/emoji/1f1e8-1f1e6.png
new file mode 100644
index 000000000..57f487c22
--- /dev/null
+++ b/webapp/images/emoji/1f1e8-1f1e6.png
Binary files differ
diff --git a/webapp/images/emoji/1f1f5-1f1f0.png b/webapp/images/emoji/1f1f5-1f1f0.png
new file mode 100644
index 000000000..17c4f6db5
--- /dev/null
+++ b/webapp/images/emoji/1f1f5-1f1f0.png
Binary files differ
diff --git a/webapp/images/emoji/1f1ff-1e1e6.png b/webapp/images/emoji/1f1ff-1e1e6.png
new file mode 100644
index 000000000..8909fe82a
--- /dev/null
+++ b/webapp/images/emoji/1f1ff-1e1e6.png
Binary files differ
diff --git a/webapp/images/emoji/1f641.png b/webapp/images/emoji/1f641.png
new file mode 100644
index 000000000..7041b0804
--- /dev/null
+++ b/webapp/images/emoji/1f641.png
Binary files differ
diff --git a/webapp/images/emoji/1f642.png b/webapp/images/emoji/1f642.png
new file mode 100644
index 000000000..abd534797
--- /dev/null
+++ b/webapp/images/emoji/1f642.png
Binary files differ
diff --git a/webapp/images/emoji/1f643.png b/webapp/images/emoji/1f643.png
new file mode 100644
index 000000000..3cb9f962f
--- /dev/null
+++ b/webapp/images/emoji/1f643.png
Binary files differ
diff --git a/webapp/package.json b/webapp/package.json
index 6f50962a4..01674ba1c 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -10,6 +10,7 @@
"compass-mixins": "0.12.7",
"fastclick": "1.0.6",
"flux": "2.1.1",
+ "font-awesome": "4.5.0",
"highlight.js": "9.2.0",
"intl": "1.1.0",
"jasny-bootstrap": "3.1.3",
@@ -56,6 +57,7 @@
"scripts": {
"check": "eslint --ext \".jsx\" --ignore-pattern node_modules --quiet .",
"build": "webpack",
- "run": "webpack --progress --watch"
+ "run": "webpack --progress --watch",
+ "run-fullmap": "webpack --progress --watch"
}
}
diff --git a/webapp/root.jsx b/webapp/root.jsx
index 2318c0682..da5980c33 100644
--- a/webapp/root.jsx
+++ b/webapp/root.jsx
@@ -4,23 +4,21 @@
import $ from 'jquery';
require('perfect-scrollbar/jquery')($);
-import 'bootstrap/dist/css/bootstrap.css';
-import 'jasny-bootstrap/dist/css/jasny-bootstrap.css';
import 'bootstrap-colorpicker/dist/css/bootstrap-colorpicker.css';
import 'google-fonts/google-fonts.css';
import 'sass/styles.scss';
import React from 'react';
import ReactDOM from 'react-dom';
-import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-router';
+import {Router, Route, IndexRoute, IndexRedirect, Redirect, browserHistory} from 'react-router';
import Root from 'components/root.jsx';
-import Login from 'components/login.jsx';
import LoggedIn from 'components/logged_in.jsx';
import NotLoggedIn from 'components/not_logged_in.jsx';
import NeedsTeam from 'components/needs_team.jsx';
import PasswordResetSendLink from 'components/password_reset_send_link.jsx';
import PasswordResetForm from 'components/password_reset_form.jsx';
import ChannelView from 'components/channel_view.jsx';
+import PermalinkView from 'components/permalink_view.jsx';
import Sidebar from 'components/sidebar.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
@@ -30,12 +28,21 @@ import BrowserStore from 'stores/browser_store.jsx';
import SignupTeam from 'components/signup_team.jsx';
import * as Client from 'utils/client.jsx';
import * as Websockets from 'action_creators/websocket_actions.jsx';
+import * as Utils from 'utils/utils.jsx';
import * as GlobalActions from 'action_creators/global_actions.jsx';
import SignupTeamConfirm from 'components/signup_team_confirm.jsx';
import SignupUserComplete from 'components/signup_user_complete.jsx';
import ShouldVerifyEmail from 'components/should_verify_email.jsx';
import DoVerifyEmail from 'components/do_verify_email.jsx';
import AdminConsole from 'components/admin_console/admin_controller.jsx';
+import TutorialView from 'components/tutorial/tutorial_view.jsx';
+import BackstageNavbar from 'components/backstage/backstage_navbar.jsx';
+import BackstageSidebar from 'components/backstage/backstage_sidebar.jsx';
+import InstalledIntegrations from 'components/backstage/installed_integrations.jsx';
+import AddIntegration from 'components/backstage/add_integration.jsx';
+import AddIncomingWebhook from 'components/backstage/add_incoming_webhook.jsx';
+import AddOutgoingWebhook from 'components/backstage/add_outgoing_webhook.jsx';
+import ErrorPage from 'components/error_page.jsx';
import SignupTeamComplete from 'components/signup_team_complete/components/signup_team_complete.jsx';
import WelcomePage from 'components/signup_team_complete/components/team_signup_welcome_page.jsx';
@@ -52,8 +59,17 @@ import OAuthToEmail from 'components/claim/components/oauth_to_email.jsx';
import LDAPToEmail from 'components/claim/components/ldap_to_email.jsx';
import EmailToLDAP from 'components/claim/components/email_to_ldap.jsx';
+import Login from 'components/login/login.jsx';
+
import * as I18n from 'i18n/i18n.jsx';
+const notFoundParams = {
+ title: Utils.localizeMessage('error.not_found.title', 'Page not found'),
+ message: Utils.localizeMessage('error.not_found.message', 'The page you where trying to reach does not exist'),
+ link: '/',
+ linkmessage: Utils.localizeMessage('error.not_found.link_message', 'Back to Mattermost')
+};
+
// This is for anything that needs to be done for ALL react components.
// This runs before we start to render anything.
function preRenderSetup(callwhendone) {
@@ -119,7 +135,7 @@ function preRenderSetup(callwhendone) {
if (global.Intl) {
afterIntl();
} else {
- I18n.safarifix(afterIntl);
+ I18n.safariFix(afterIntl);
}
}
@@ -135,20 +151,11 @@ function preLoggedIn(nextState, replace, callback) {
const d2 = AsyncClient.getChannels();
- $.when(d1, d2).done(() => callback());
-}
-
-function onChannelChange(nextState) {
- const channelName = nextState.params.channel;
+ ErrorStore.clearLastError();
- // Make sure we have all the channels
- AsyncClient.getChannels(true);
-
- // Get our channel's ID
- const channel = ChannelStore.getByName(channelName);
-
- // User clicked channel
- GlobalActions.emitChannelClickEvent(channel);
+ $.when(d1, d2).done(() => {
+ callback();
+ });
}
function onRootEnter(nextState, replace, callback) {
@@ -174,6 +181,26 @@ function onPermalinkEnter(nextState) {
GlobalActions.emitPostFocusEvent(postId);
}
+function onChannelEnter(nextState) {
+ doChannelChange(nextState);
+}
+
+function onChannelChange(prevState, nextState) {
+ if (prevState.params.channel !== nextState.params.channel) {
+ doChannelChange(nextState);
+ }
+}
+
+function doChannelChange(state) {
+ let channel;
+ if (state.location.query.fakechannel) {
+ channel = JSON.parse(state.location.query.fakechannel);
+ } else {
+ channel = ChannelStore.getByName(state.params.channel);
+ }
+ GlobalActions.emitChannelClickEvent(channel);
+}
+
function onLoggedOut(nextState) {
const teamName = nextState.params.team;
Client.logout(
@@ -201,12 +228,17 @@ function renderRootComponent() {
onEnter={onRootEnter}
>
<Route
+ path='error'
+ component={ErrorPage}
+ />
+ <Route
component={LoggedIn}
onEnter={preLoggedIn}
>
<Route
path=':team/channels/:channel'
- onEnter={onChannelChange}
+ onEnter={onChannelEnter}
+ onChange={onChannelChange}
components={{
sidebar: Sidebar,
center: ChannelView
@@ -217,23 +249,64 @@ function renderRootComponent() {
onEnter={onPermalinkEnter}
components={{
sidebar: Sidebar,
- center: ChannelView
+ center: PermalinkView
}}
/>
<Route
- path=':team/logout'
- onEnter={onLoggedOut}
+ path=':team/tutorial'
components={{
- sidebar: null,
- center: null
+ sidebar: Sidebar,
+ center: TutorialView
}}
/>
<Route
+ path=':team/logout'
+ onEnter={onLoggedOut}
+ />
+ <Route path='settings/integrations'>
+ <IndexRedirect to='installed'/>
+ <Route
+ path='installed'
+ components={{
+ navbar: BackstageNavbar,
+ sidebar: BackstageSidebar,
+ center: InstalledIntegrations
+ }}
+ />
+ <Route path='add'>
+ <IndexRoute
+ components={{
+ navbar: BackstageNavbar,
+ sidebar: BackstageSidebar,
+ center: AddIntegration
+ }}
+ />
+ <Route
+ path='incoming_webhook'
+ components={{
+ navbar: BackstageNavbar,
+ sidebar: BackstageSidebar,
+ center: AddIncomingWebhook
+ }}
+ />
+ <Route
+ path='outgoing_webhook'
+ components={{
+ navbar: BackstageNavbar,
+ sidebar: BackstageSidebar,
+ center: AddOutgoingWebhook
+ }}
+ />
+ </Route>
+ <Redirect
+ from='*'
+ to='/error'
+ query={notFoundParams}
+ />
+ </Route>
+ <Route
path='admin_console'
- components={{
- sidebar: null,
- center: AdminConsole
- }}
+ component={AdminConsole}
/>
</Route>
<Route component={NotLoggedIn}>
@@ -325,6 +398,11 @@ function renderRootComponent() {
component={LDAPToEmail}
/>
</Route>
+ <Redirect
+ from='*'
+ to='/error'
+ query={notFoundParams}
+ />
</Route>
</Route>
</Route>
diff --git a/webapp/sass/components/_mentions.scss b/webapp/sass/components/_mentions.scss
index 98ae7d320..4753b4e9a 100644
--- a/webapp/sass/components/_mentions.scss
+++ b/webapp/sass/components/_mentions.scss
@@ -47,7 +47,7 @@
}
.mention__fullname {
- color: $dark-gray;
+ @include opacity(.5);
padding-left: 10px;
}
diff --git a/webapp/sass/components/_modal.scss b/webapp/sass/components/_modal.scss
index 4e2049857..2348788f4 100644
--- a/webapp/sass/components/_modal.scss
+++ b/webapp/sass/components/_modal.scss
@@ -39,6 +39,38 @@
}
}
+ .padding-top {
+ padding-top: 7px;
+
+ &.x2 {
+ padding-top: 14px;
+ }
+
+ &.x3 {
+ padding-top: 21px;
+ }
+ }
+
+ .padding-bottom {
+ padding-bottom: 7px;
+
+ &.x2 {
+ padding-bottom: 14px;
+ }
+
+ &.x3 {
+ padding-bottom: 21px;
+ }
+
+ .control-label {
+ font-weight: 600;
+
+ &.text-left {
+ text-align: left;
+ }
+ }
+ }
+
.custom-textarea {
border-color: $light-gray;
color: inherit;
diff --git a/webapp/sass/layout/_forms.scss b/webapp/sass/layout/_forms.scss
index 259beeb57..1dd2bb827 100644
--- a/webapp/sass/layout/_forms.scss
+++ b/webapp/sass/layout/_forms.scss
@@ -12,7 +12,7 @@
text-align: left;
&.light {
- color: $dark-gray;
+ @include opacity(.6);
font-size: 1.05em;
font-style: italic;
font-weight: normal;
diff --git a/webapp/sass/layout/_post.scss b/webapp/sass/layout/_post.scss
index 4170483db..947a81318 100644
--- a/webapp/sass/layout/_post.scss
+++ b/webapp/sass/layout/_post.scss
@@ -66,6 +66,7 @@ body.ios {
font-size: 13px;
position: absolute;
right: 0;
+ text-align: right;
z-index: 3;
}
@@ -74,16 +75,14 @@ body.ios {
}
}
-.help_format_text {
+.help__format-text {
@include opacity(0);
- @include single-transition(all .2s ease);
- bottom: -23px;
- display: none !important;
+ @include single-transition(all, .5s, ease, .5s);
+ display: inline-block;
font-size: .85em;
- left: 0;
- overflow: hidden;
- position: absolute;
- text-overflow: ellipsis;
+ margin-right: 10px;
+ vertical-align: bottom;
+ white-space: nowrap;
b,
i,
@@ -275,39 +274,36 @@ body.ios {
outline: none;
text-align: center;
}
-
- .beginning-messages-text {
- color: grey;
- display: block;
- margin-bottom: 5px;
- margin-top: 2px;
- text-align: center;
- }
}
.post-list__timestamp {
- @include border-radius(3px);
@include opacity(0);
@include single-transition(all, .6s, ease);
@include translateY(-45px);
- @include font-smoothing(initial);
- background: $primary-color;
- color: $white;
display: none;
- font-size: 12px;
- left: 50%;
- line-height: 25px;
- margin-left: -60px;
+ left: 0;
position: absolute;
text-align: center;
top: 8px;
- width: 120px;
+ width: 100%;
z-index: 50;
&.scrolling {
@include translateY(0);
@include opacity(.8);
}
+
+ > div {
+ @include border-radius(3px);
+ @include font-smoothing(initial);
+ background: $primary-color;
+ color: $white;
+ display: inline-block;
+ font-size: 12px;
+ line-height: 25px;
+ padding: 0 8px;
+ text-align: center;
+ }
}
.post-list__arrows {
@@ -381,6 +377,7 @@ body.ios {
}
.custom-textarea {
+ bottom: 0;
line-height: 1.5;
max-height: 162px !important;
padding-right: 28px;
@@ -647,6 +644,15 @@ body.ios {
.post__img {
width: 46px;
+ svg {
+ height: 36px;
+ width: 36px;
+ }
+
+ path {
+ fill: inherit;
+ }
+
img {
@include border-radius(50px);
height: 36px;
@@ -752,7 +758,7 @@ body.ios {
li ul,
li ol {
- padding: 0 0 0 20px;
+ padding: 10px 0 0 20px;
}
li.list-item--task-list ul,
diff --git a/webapp/sass/layout/_sidebar-right.scss b/webapp/sass/layout/_sidebar-right.scss
index a7b631047..062c3bde1 100644
--- a/webapp/sass/layout/_sidebar-right.scss
+++ b/webapp/sass/layout/_sidebar-right.scss
@@ -25,6 +25,10 @@
}
}
+ .help__format-text {
+ display: none;
+ }
+
.sidebar--right__content {
@include display-flex;
@include flex-direction(column);
diff --git a/webapp/sass/responsive/_desktop.scss b/webapp/sass/responsive/_desktop.scss
index ccd6f0226..3b36fb75f 100644
--- a/webapp/sass/responsive/_desktop.scss
+++ b/webapp/sass/responsive/_desktop.scss
@@ -17,6 +17,14 @@
}
@media screen and (max-width: 1440px) {
+ .inner-wrap {
+ &.move--left {
+ .help__format-text {
+ display: none;
+ }
+ }
+ }
+
.date-separator,
.new-separator {
&.hovered--comment {
@@ -40,6 +48,11 @@
}
}
+ .backstage-content {
+ margin: 46px 46px 46px 150px;
+ }
+
+
.inner-wrap {
&.move--left {
.file-overlay {
diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss
index 0e1a471cf..38476485d 100644
--- a/webapp/sass/responsive/_mobile.scss
+++ b/webapp/sass/responsive/_mobile.scss
@@ -1,6 +1,16 @@
@charset 'UTF-8';
@media screen and (max-width: 768px) {
+ .backstage-filters {
+ display: block;
+
+ .backstage-filter__search {
+ border-bottom: 1px solid $light-gray;
+ margin: 10px 0;
+ width: 100%;
+ }
+ }
+
.signup-team__container {
font-size: 1em;
}
@@ -675,9 +685,9 @@
}
.sidebar--right {
- width: 100%;
- right: 0;
@include translate3d(100%, 0, 0);
+ right: 0;
+ width: 100%;
z-index: 5;
&.move--left {
@@ -786,6 +796,40 @@
}
@media screen and (max-width: 640px) {
+ .modal {
+ .about-modal {
+ .about-modal__content {
+ display: block;
+ }
+
+ .about-modal__hash {
+ p {
+ word-break: break-all;
+
+ &:first-child {
+ float: none;
+ }
+ }
+ }
+
+ .about-modal__logo {
+ float: none;
+ padding: 0;
+ text-align: center;
+ width: 100%;
+
+ svg {
+ height: 100px;
+ width: 100px;
+ }
+ }
+
+ .about-modal__logo + div {
+ padding: 2em 0 0;
+ }
+ }
+ }
+
.access-history__table {
> div {
display: block;
@@ -819,6 +863,30 @@
}
@media screen and (max-width: 480px) {
+ .backstage-header {
+ h1 {
+ float: none;
+ margin-bottom: 15px;
+ }
+
+ .add-integrations-link {
+ float: none;
+ }
+ }
+
+ .add-integration {
+ width: 100%;
+ }
+
+ .backstage-list__item {
+ display: block;
+
+ .actions {
+ margin-top: 10px;
+ padding: 0;
+ }
+ }
+
.modal {
.settings-modal {
.settings-table {
diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss
index 0a725a558..db2a8d7b9 100644
--- a/webapp/sass/responsive/_tablet.scss
+++ b/webapp/sass/responsive/_tablet.scss
@@ -15,6 +15,23 @@
}
}
+ .backstage-content {
+ margin: 30px;
+ max-width: 100%;
+ padding: 0;
+ }
+
+ .backstage-sidebar {
+ height: auto;
+ padding: 30px 15px 0;
+ position: relative;
+ width: 100%;
+ }
+
+ .help__format-text {
+ display: none;
+ }
+
.inner-wrap {
&.move--left {
margin-right: 0;
diff --git a/webapp/sass/routes/_about-modal.scss b/webapp/sass/routes/_about-modal.scss
new file mode 100644
index 000000000..98119c8aa
--- /dev/null
+++ b/webapp/sass/routes/_about-modal.scss
@@ -0,0 +1,78 @@
+@charset 'UTF-8';
+
+.modal {
+ .about-modal {
+ .modal-header {
+ background: transparent;
+ border: none;
+ color: inherit;
+ padding: 20px 25px 0;
+
+ .close {
+ color: inherit;
+ font-weight: normal;
+ right: 15px;
+ }
+
+ .modal-title {
+ color: inherit;
+ font-size: 16px;
+ }
+ }
+
+ .modal-body {
+ padding: 20px 25px 5px;
+ }
+
+ .about-modal__content {
+ @include clearfix;
+ @include display-flex;
+ @include flex-direction(row);
+ padding: 1em 0 3em;
+ }
+
+ .about-modal__copyright {
+ @include opacity(.6);
+ margin-top: .5em;
+ }
+
+ .about-modal__footer {
+ font-size: 13.5px;
+ }
+
+ .about-modal__title {
+ line-height: 1.5;
+ margin: 0 0 10px;
+ }
+
+ .about-modal__subtitle {
+ @include opacity(.6);
+ }
+
+ .about-modal__hash {
+ @include opacity(.4);
+ font-size: .75em;
+ text-align: right;
+
+ p {
+ &:first-child {
+ float: left;
+ }
+ }
+ }
+
+ .about-modal__logo {
+ @include opacity(.9);
+ padding: 0 40px 0 20px;
+
+ svg {
+ height: 125px;
+ width: 125px;
+ }
+
+ path {
+ fill: inherit;
+ }
+ }
+ }
+}
diff --git a/webapp/sass/routes/_backstage.scss b/webapp/sass/routes/_backstage.scss
new file mode 100644
index 000000000..729c8c912
--- /dev/null
+++ b/webapp/sass/routes/_backstage.scss
@@ -0,0 +1,267 @@
+.backstage-content {
+ background-color: $bg--gray;
+ height: 100%;
+ margin: 46px auto;
+ max-width: 960px;
+ padding-left: 135px;
+}
+
+.backstage-navbar {
+ background: $white;
+ border-bottom: 1px solid $light-gray;
+ padding: 10px 20px;
+ z-index: 10;
+}
+
+.backstage-navbar__back {
+ color: inherit;
+ text-decoration: none;
+
+ .fa {
+ font-size: 1.1em;
+ font-weight: bold;
+ margin-right: 7px;
+ }
+
+ &:hover,
+ &:active {
+ color: inherit;
+ }
+}
+
+.backstage-sidebar {
+ height: 100%;
+ left: 0;
+ padding: 50px 20px;
+ position: absolute;
+ width: 260px;
+ z-index: 5;
+
+ ul {
+ list-style: none;
+ padding: 0;
+ }
+}
+
+.backstage-sidebar__category {
+ border: 1px solid $light-gray;
+
+ .category-title {
+ display: block;
+ line-height: 36px;
+ padding: 0 10px;
+ position: relative;
+ }
+
+ .category-title--active {
+ color: $black;
+ }
+
+ .category-title__text {
+ left: 2em;
+ position: absolute;
+ }
+
+ .sections {
+ background: $white;
+ border-top: 1px solid $light-gray;
+ }
+
+ .section-title,
+ .subsection-title {
+ display: block;
+ font-size: .95em;
+ line-height: 29px;
+ padding-left: 2em;
+ text-decoration: none;
+ }
+
+ .subsection-title {
+ padding-left: 3em;
+ }
+
+ .section-title--active,
+ .subsection-title--active {
+ background-color: $primary-color;
+ color: $white;
+ font-weight: 600;
+ }
+}
+
+.backstage__sidebar__category + .backstage__sidebar__category {
+ border-top-width: 0;
+}
+
+.backstage-header {
+ @include clearfix;
+ margin-bottom: 20px;
+ width: 100%;
+
+ h1 {
+ float: left;
+ font-size: 20px;
+ margin: 5px 0;
+ }
+
+ .add-integrations-link {
+ float: right;
+ }
+}
+
+.backstage-filters {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+
+ .backstage-filters__sort {
+ flex-grow: 1;
+ flex-shrink: 0;
+ line-height: 30px;
+
+ .filter-sort {
+ text-decoration: none;
+
+ &.filter-sort--active {
+ color: inherit;
+ cursor: default;
+ }
+ }
+
+ .divider {
+ margin-left: 8px;
+ margin-right: 8px;
+ }
+ }
+
+ .backstage-filter__search {
+ flex-grow: 0;
+ flex-shrink: 0;
+ position: relative;
+ width: 270px;
+
+ .fa {
+ @include opacity(.4);
+ left: 11px;
+ position: absolute;
+ top: 11px;
+ }
+
+ input {
+ background: $white;
+ border-bottom: none;
+ padding-left: 30px;
+ }
+ }
+}
+
+.backstage-list {
+ background-color: $white;
+ border: 1px solid $light-gray;
+ padding: 5px 15px;
+}
+
+.backstage-list__item {
+ border-bottom: 1px solid $light-gray;
+ display: flex;
+ padding: 20px 15px;
+
+ &:last-child {
+ border: none;
+ }
+
+ .item-details {
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .item-details__row + .item-details__row {
+ @include clearfix;
+ margin-top: 10px;
+ text-overflow: ellipsis;
+ }
+
+ .item-details__name {
+ font-weight: 600;
+ margin-bottom: 1em;
+ }
+
+ .item-details__type {
+ margin-left: 6px;
+ }
+
+ .item-details__description {
+ color: $dark-gray;
+ margin-bottom: 1em;
+ }
+
+ .list-item__actions {
+ flex-grow: 0;
+ flex-shrink: 0;
+ padding-left: 20px;
+ }
+}
+
+// Backstage Form
+
+.backstage-form {
+ background-color: $white;
+ border: 1px solid $light-gray;
+ padding: 40px 30px 30px;
+
+ label {
+ font-weight: normal;
+ }
+
+ .form-control {
+ background: $white;
+
+ &:focus {
+ border-color: $primary-color;
+ }
+ }
+}
+
+.backstage-form__footer {
+ border-top: 1px solid $light-gray;
+ margin-top: 2.5em;
+ padding-top: 1.8em;
+ text-align: right;
+
+ .has-error {
+ float: left;
+ margin: 0;
+ }
+}
+
+.add-integration {
+ background-color: $white;
+ border: 1px solid $light-gray;
+ display: inline-block;
+ height: 210px;
+ margin: 0 30px 20px 0;
+ padding: 20px;
+ text-align: center;
+ vertical-align: top;
+ width: 250px;
+
+ &:hover {
+ color: default;
+ text-decoration: none;
+ }
+}
+
+.add-integration__image {
+ height: 80px;
+ width: 80px;
+}
+
+.add-integration__title {
+ color: $black;
+ margin-bottom: 10px;
+}
+
+.add-integration__description {
+ color: $dark-gray;
+}
diff --git a/webapp/sass/routes/_module.scss b/webapp/sass/routes/_module.scss
index 48c1af1d9..4f3f6f9cd 100644
--- a/webapp/sass/routes/_module.scss
+++ b/webapp/sass/routes/_module.scss
@@ -1,7 +1,9 @@
// Only for combining all the files in this folder
+@import 'about-modal';
@import 'access-history';
@import 'activity-log';
@import 'admin-console';
+@import 'backstage';
@import 'docs';
@import 'error-page';
@import 'loading';
diff --git a/webapp/sass/routes/_settings.scss b/webapp/sass/routes/_settings.scss
index 1c3f2e308..1551e5f4d 100644
--- a/webapp/sass/routes/_settings.scss
+++ b/webapp/sass/routes/_settings.scss
@@ -51,38 +51,6 @@
padding-left: 0;
}
- .padding-top {
- padding-top: 7px;
-
- &.x2 {
- padding-top: 14px;
- }
-
- &.x3 {
- padding-top: 21px;
- }
- }
-
- .padding-bottom {
- padding-bottom: 7px;
-
- &.x2 {
- padding-bottom: 14px;
- }
-
- &.x3 {
- padding-bottom: 21px;
- }
-
- .control-label {
- font-weight: 600;
-
- &.text-left {
- text-align: left;
- }
- }
- }
-
.profile-img {
height: 128px;
width: 128px;
diff --git a/webapp/sass/routes/_signup.scss b/webapp/sass/routes/_signup.scss
index 09f8e4185..6d6092170 100644
--- a/webapp/sass/routes/_signup.scss
+++ b/webapp/sass/routes/_signup.scss
@@ -98,7 +98,6 @@
.inner__content {
margin: 30px 0 20px;
- padding: 0 1rem;
}
.block--gray {
@@ -132,14 +131,12 @@
font-size: 2.2em;
font-weight: 600;
margin: .5em 0 0;
- padding-left: 1rem;
}
.signup-team__subdomain {
font-size: 1.5em;
font-weight: 300;
margin: .2em 0 1.2em;
- padding-left: 1rem;
text-transform: uppercase;
}
@@ -151,6 +148,7 @@
background: #dddddd;
height: 1px;
margin: 2em 0;
+ margin: 2.5em 0 2.5em -1rem;
text-align: left;
span {
@@ -171,10 +169,6 @@
padding-left: 18px;
}
- .signup__email-container {
- margin-left: 1rem;
- }
-
.btn {
font-size: 1em;
font-weight: 600;
@@ -200,7 +194,7 @@
display: block;
height: 40px;
line-height: 34px;
- margin: 1em 1rem;
+ margin: 1em 0;
min-width: 200px;
padding: 0 1em;
width: 200px;
@@ -262,9 +256,9 @@
}
&.btn-full {
- width: 100%;
- text-align: left;
padding-left: 35px;
+ text-align: left;
+ width: 100%;
}
}
@@ -373,11 +367,11 @@
}
.margin--extra {
- margin-top: 3em;
+ margin-top: 2.5em;
}
.margin--extra-2x {
- margin-top: 6em;
+ margin-top: 5em;
}
}
diff --git a/webapp/sass/styles.scss b/webapp/sass/styles.scss
index 88c098f18..67e62d023 100644
--- a/webapp/sass/styles.scss
+++ b/webapp/sass/styles.scss
@@ -1,18 +1,19 @@
-@charset "UTF-8";
+@charset 'UTF-8';
-/* Welcome to Compass.
- * In this file you should write your main styles. (or centralize your imports)
- * Import this file using the following HTML or equivalent:
- * <link href="/stylesheets/screen.css" media="screen, projection" rel="stylesheet" type="text/css" /> */
+@import 'compass/utilities';
+@import 'compass/css3';
-@import "compass/utilities";
-@import "compass/css3";
+// Dependancies
+@import '~bootstrap/dist/css/bootstrap.css';
+@import '~jasny-bootstrap/dist/css/jasny-bootstrap.css';
+@import '~perfect-scrollbar/dist/css/perfect-scrollbar.css';
+@import '~font-awesome/css/font-awesome.css';
+@import '~bootstrap-colorpicker/dist/css/bootstrap-colorpicker.css';
// styles.scss
-@import 'vendors/module';
@import 'utils/module';
@import 'base/module';
@import 'routes/module';
@import 'components/module';
@import 'layout/module';
-@import 'responsive/module'; \ No newline at end of file
+@import 'responsive/module';
diff --git a/webapp/sass/utils/_variables.scss b/webapp/sass/utils/_variables.scss
index 345ab11e8..53004520e 100644
--- a/webapp/sass/utils/_variables.scss
+++ b/webapp/sass/utils/_variables.scss
@@ -8,7 +8,7 @@ $white: rgb(255, 255, 255);
$black: rgb(0, 0, 0);
$red: rgb(229, 101, 101);
$yellow: rgb(255, 255, 0);
-$light-gray: rgba(0, 0, 0, .06);
+$light-gray: rgba(0, 0, 0, .15);
$gray: rgba(0, 0, 0, .3);
$dark-gray: rgba(0, 0, 0, .5);
diff --git a/webapp/sass/vendors/_colorpicker.scss b/webapp/sass/vendors/_colorpicker.scss
deleted file mode 100644
index 291145e80..000000000
--- a/webapp/sass/vendors/_colorpicker.scss
+++ /dev/null
@@ -1,253 +0,0 @@
-@charset 'UTF-8';
-
-/*!
- * Bootstrap Colorpicker
- * http://mjolnic.github.io/bootstrap-colorpicker/
- *
- * Originally written by (c) 2012 Stefan Petre
- * Licensed under the Apache License v2.0
- * http://www.apache.org/licenses/LICENSE-2.0.txt
- *
- */
-
-.colorpicker-saturation {
- float: left;
- width: 100px;
- height: 100px;
- cursor: crosshair;
- background-image: url('../images/bootstrap-colorpicker/saturation.png');
-}
-
-.colorpicker-saturation i {
- position: absolute;
- top: 0;
- left: 0;
- display: block;
- width: 5px;
- height: 5px;
- margin: -4px 0 0 -4px;
- border: 1px solid #000;
- -webkit-border-radius: 5px;
- -moz-border-radius: 5px;
- border-radius: 5px;
-}
-
-.colorpicker-saturation i b {
- display: block;
- width: 5px;
- height: 5px;
- border: 1px solid #fff;
- -webkit-border-radius: 5px;
- -moz-border-radius: 5px;
- border-radius: 5px;
-}
-
-.colorpicker-hue,
-.colorpicker-alpha {
- float: left;
- width: 15px;
- height: 100px;
- margin-bottom: 4px;
- margin-left: 4px;
- cursor: row-resize;
-}
-
-.colorpicker-hue i,
-.colorpicker-alpha i {
- position: absolute;
- top: 0;
- left: 0;
- display: block;
- width: 100%;
- height: 1px;
- margin-top: -1px;
- background: #000;
- border-top: 1px solid #fff;
-}
-
-.colorpicker-hue {
- background-image: url('../images/bootstrap-colorpicker/hue.png');
-}
-
-.colorpicker-alpha {
- display: none;
- background-image: url('../images/bootstrap-colorpicker/alpha.png');
-}
-
-.colorpicker-saturation,
-.colorpicker-hue,
-.colorpicker-alpha {
- background-size: contain;
-}
-
-.colorpicker {
- top: 0;
- left: 0;
- z-index: 2500;
- min-width: 130px;
- padding: 4px;
- margin-top: 1px;
- -webkit-border-radius: 4px;
- -moz-border-radius: 4px;
- border-radius: 4px;
- *zoom: 1;
-}
-
-.colorpicker:before,
-.colorpicker:after {
- display: table;
- line-height: 0;
- content: '';
-}
-
-.colorpicker:after {
- clear: both;
-}
-
-.colorpicker:before {
- position: absolute;
- top: -7px;
- left: 6px;
- display: inline-block;
- border-right: 7px solid transparent;
- border-bottom: 7px solid #ccc;
- border-left: 7px solid transparent;
- border-bottom-color: rgba(0, 0, 0, .2);
- content: '';
-}
-
-.colorpicker:after {
- position: absolute;
- top: -6px;
- left: 7px;
- display: inline-block;
- border-right: 6px solid transparent;
- border-bottom: 6px solid #fff;
- border-left: 6px solid transparent;
- content: '';
-}
-
-.colorpicker div {
- position: relative;
-}
-
-.colorpicker.colorpicker-with-alpha {
- min-width: 140px;
-}
-
-.colorpicker.colorpicker-with-alpha .colorpicker-alpha {
- display: block;
-}
-
-.colorpicker-color {
- height: 10px;
- margin-top: 5px;
- clear: both;
- background-image: url('../images/bootstrap-colorpicker/alpha.png');
- background-position: 0 100%;
-}
-
-.colorpicker-color div {
- height: 10px;
-}
-
-.colorpicker-selectors {
- display: none;
- height: 10px;
- margin-top: 5px;
- clear: both;
-}
-
-.colorpicker-selectors i {
- float: left;
- width: 10px;
- height: 10px;
- cursor: pointer;
-}
-
-.colorpicker-selectors i + i {
- margin-left: 3px;
-}
-
-.colorpicker-element .input-group-addon i,
-.colorpicker-element .add-on i {
- display: inline-block;
- width: 16px;
- height: 16px;
- vertical-align: text-top;
- cursor: pointer;
-}
-
-.colorpicker.colorpicker-inline {
- position: relative;
- z-index: auto;
- display: inline-block;
- float: none;
-}
-
-.colorpicker.colorpicker-horizontal {
- width: 110px;
- height: auto;
- min-width: 110px;
-}
-
-.colorpicker.colorpicker-horizontal .colorpicker-saturation {
- margin-bottom: 4px;
-}
-
-.colorpicker.colorpicker-horizontal .colorpicker-color {
- width: 100px;
-}
-
-.colorpicker.colorpicker-horizontal .colorpicker-hue,
-.colorpicker.colorpicker-horizontal .colorpicker-alpha {
- float: left;
- width: 100px;
- height: 15px;
- margin-bottom: 4px;
- margin-left: 0;
- cursor: col-resize;
-}
-
-.colorpicker.colorpicker-horizontal .colorpicker-hue i,
-.colorpicker.colorpicker-horizontal .colorpicker-alpha i {
- position: absolute;
- top: 0;
- left: 0;
- display: block;
- width: 1px;
- height: 15px;
- margin-top: 0;
- background: #fff;
- border: none;
-}
-
-.colorpicker.colorpicker-horizontal .colorpicker-hue {
- background-image: url('../images/bootstrap-colorpicker/hue-horizontal.png');
-}
-
-.colorpicker.colorpicker-horizontal .colorpicker-alpha {
- background-image: url('../images/bootstrap-colorpicker/alpha-horizontal.png');
-}
-
-.colorpicker.colorpicker-hidden {
- display: none;
-}
-
-.colorpicker.colorpicker-visible {
- display: block;
-}
-
-.colorpicker-inline.colorpicker-visible {
- display: inline-block;
-}
-
-.colorpicker-right:before {
- right: 6px;
- left: auto;
-}
-
-.colorpicker-right:after {
- right: 7px;
- left: auto;
-}
diff --git a/webapp/sass/vendors/_font-awesome.scss b/webapp/sass/vendors/_font-awesome.scss
deleted file mode 100644
index 49ab318cd..000000000
--- a/webapp/sass/vendors/_font-awesome.scss
+++ /dev/null
@@ -1,1803 +0,0 @@
-@charset 'UTF-8';
-
-/*!
- * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome
- * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
- */
-/* FONT PATH
- * -------------------------- */
-@font-face {
- font-family: 'FontAwesome';
- src: url('../fonts/fontawesome-webfont.eot?v=4.3.0');
- src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.3.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.3.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.3.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular') format('svg');
- font-weight: normal;
- font-style: normal;
-}
-.fa {
- display: inline-block;
- font: normal normal normal 14px/1 FontAwesome;
- font-size: inherit;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- transform: translate(0, 0);
-}
-/* makes the font 33% larger relative to the icon container */
-.fa-lg {
- font-size: 1.33333333em;
- line-height: .75em;
- vertical-align: -15%;
-}
-.fa-2x {
- font-size: 2em;
-}
-.fa-3x {
- font-size: 3em;
-}
-.fa-4x {
- font-size: 4em;
-}
-.fa-5x {
- font-size: 5em;
-}
-.fa-fw {
- width: 1.28571429em;
- text-align: center;
-}
-.fa-ul {
- padding-left: 0;
- margin-left: 2.14285714em;
- list-style-type: none;
-}
-.fa-ul > li {
- position: relative;
-}
-.fa-li {
- position: absolute;
- left: -2.14285714em;
- width: 2.14285714em;
- top: .14285714em;
- text-align: center;
-}
-.fa-li.fa-lg {
- left: -1.85714286em;
-}
-.fa-border {
- padding: .2em .25em .15em;
- border: solid .08em #eee;
- border-radius: .1em;
-}
-.pull-right {
- float: right;
-}
-.pull-left {
- float: left;
-}
-.fa.pull-left {
- margin-right: .3em;
-}
-.fa.pull-right {
- margin-left: .3em;
-}
-.fa-spin {
- -webkit-animation: fa-spin 2s infinite linear;
- animation: fa-spin 2s infinite linear;
-}
-.fa-pulse {
- -webkit-animation: fa-spin 1s infinite steps(8);
- animation: fa-spin 1s infinite steps(8);
-}
-@-webkit-keyframes fa-spin {
- 0% {
- -webkit-transform: rotate(0deg);
- transform: rotate(0deg);
- }
- 100% {
- -webkit-transform: rotate(359deg);
- transform: rotate(359deg);
- }
-}
-@keyframes fa-spin {
- 0% {
- -webkit-transform: rotate(0deg);
- transform: rotate(0deg);
- }
- 100% {
- -webkit-transform: rotate(359deg);
- transform: rotate(359deg);
- }
-}
-.fa-rotate-90 {
- filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1);
- -webkit-transform: rotate(90deg);
- -ms-transform: rotate(90deg);
- transform: rotate(90deg);
-}
-.fa-rotate-180 {
- filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2);
- -webkit-transform: rotate(180deg);
- -ms-transform: rotate(180deg);
- transform: rotate(180deg);
-}
-.fa-rotate-270 {
- filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3);
- -webkit-transform: rotate(270deg);
- -ms-transform: rotate(270deg);
- transform: rotate(270deg);
-}
-.fa-flip-horizontal {
- filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);
- -webkit-transform: scale(-1, 1);
- -ms-transform: scale(-1, 1);
- transform: scale(-1, 1);
-}
-.fa-flip-vertical {
- filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);
- -webkit-transform: scale(1, -1);
- -ms-transform: scale(1, -1);
- transform: scale(1, -1);
-}
-:root .fa-rotate-90,
-:root .fa-rotate-180,
-:root .fa-rotate-270,
-:root .fa-flip-horizontal,
-:root .fa-flip-vertical {
- filter: none;
-}
-.fa-stack {
- position: relative;
- display: inline-block;
- width: 2em;
- height: 2em;
- line-height: 2em;
- vertical-align: middle;
-}
-.fa-stack-1x,
-.fa-stack-2x {
- position: absolute;
- left: 0;
- width: 100%;
- text-align: center;
-}
-.fa-stack-1x {
- line-height: inherit;
-}
-.fa-stack-2x {
- font-size: 2em;
-}
-.fa-inverse {
- color: #fff;
-}
-/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
- readers do not read off random characters that represent icons */
-.fa-glass:before {
- content: '\f000';
-}
-.fa-music:before {
- content: '\f001';
-}
-.fa-search:before {
- content: '\f002';
-}
-.fa-envelope-o:before {
- content: '\f003';
-}
-.fa-heart:before {
- content: '\f004';
-}
-.fa-star:before {
- content: '\f005';
-}
-.fa-star-o:before {
- content: '\f006';
-}
-.fa-user:before {
- content: '\f007';
-}
-.fa-film:before {
- content: '\f008';
-}
-.fa-th-large:before {
- content: '\f009';
-}
-.fa-th:before {
- content: '\f00a';
-}
-.fa-th-list:before {
- content: '\f00b';
-}
-.fa-check:before {
- content: '\f00c';
-}
-.fa-remove:before,
-.fa-close:before,
-.fa-times:before {
- content: '\f00d';
-}
-.fa-search-plus:before {
- content: '\f00e';
-}
-.fa-search-minus:before {
- content: '\f010';
-}
-.fa-power-off:before {
- content: '\f011';
-}
-.fa-signal:before {
- content: '\f012';
-}
-.fa-gear:before,
-.fa-cog:before {
- content: '\f013';
-}
-.fa-trash-o:before {
- content: '\f014';
-}
-.fa-home:before {
- content: '\f015';
-}
-.fa-file-o:before {
- content: '\f016';
-}
-.fa-clock-o:before {
- content: '\f017';
-}
-.fa-road:before {
- content: '\f018';
-}
-.fa-download:before {
- content: '\f019';
-}
-.fa-arrow-circle-o-down:before {
- content: '\f01a';
-}
-.fa-arrow-circle-o-up:before {
- content: '\f01b';
-}
-.fa-inbox:before {
- content: '\f01c';
-}
-.fa-play-circle-o:before {
- content: '\f01d';
-}
-.fa-rotate-right:before,
-.fa-repeat:before {
- content: '\f01e';
-}
-.fa-refresh:before {
- content: '\f021';
-}
-.fa-list-alt:before {
- content: '\f022';
-}
-.fa-lock:before {
- content: '\f023';
-}
-.fa-flag:before {
- content: '\f024';
-}
-.fa-headphones:before {
- content: '\f025';
-}
-.fa-volume-off:before {
- content: '\f026';
-}
-.fa-volume-down:before {
- content: '\f027';
-}
-.fa-volume-up:before {
- content: '\f028';
-}
-.fa-qrcode:before {
- content: '\f029';
-}
-.fa-barcode:before {
- content: '\f02a';
-}
-.fa-tag:before {
- content: '\f02b';
-}
-.fa-tags:before {
- content: '\f02c';
-}
-.fa-book:before {
- content: '\f02d';
-}
-.fa-bookmark:before {
- content: '\f02e';
-}
-.fa-print:before {
- content: '\f02f';
-}
-.fa-camera:before {
- content: '\f030';
-}
-.fa-font:before {
- content: '\f031';
-}
-.fa-bold:before {
- content: '\f032';
-}
-.fa-italic:before {
- content: '\f033';
-}
-.fa-text-height:before {
- content: '\f034';
-}
-.fa-text-width:before {
- content: '\f035';
-}
-.fa-align-left:before {
- content: '\f036';
-}
-.fa-align-center:before {
- content: '\f037';
-}
-.fa-align-right:before {
- content: '\f038';
-}
-.fa-align-justify:before {
- content: '\f039';
-}
-.fa-list:before {
- content: '\f03a';
-}
-.fa-dedent:before,
-.fa-outdent:before {
- content: '\f03b';
-}
-.fa-indent:before {
- content: '\f03c';
-}
-.fa-video-camera:before {
- content: '\f03d';
-}
-.fa-photo:before,
-.fa-image:before,
-.fa-picture-o:before {
- content: '\f03e';
-}
-.fa-pencil:before {
- content: '\f040';
-}
-.fa-map-marker:before {
- content: '\f041';
-}
-.fa-adjust:before {
- content: '\f042';
-}
-.fa-tint:before {
- content: '\f043';
-}
-.fa-edit:before,
-.fa-pencil-square-o:before {
- content: '\f044';
-}
-.fa-share-square-o:before {
- content: '\f045';
-}
-.fa-check-square-o:before {
- content: '\f046';
-}
-.fa-arrows:before {
- content: '\f047';
-}
-.fa-step-backward:before {
- content: '\f048';
-}
-.fa-fast-backward:before {
- content: '\f049';
-}
-.fa-backward:before {
- content: '\f04a';
-}
-.fa-play:before {
- content: '\f04b';
-}
-.fa-pause:before {
- content: '\f04c';
-}
-.fa-stop:before {
- content: '\f04d';
-}
-.fa-forward:before {
- content: '\f04e';
-}
-.fa-fast-forward:before {
- content: '\f050';
-}
-.fa-step-forward:before {
- content: '\f051';
-}
-.fa-eject:before {
- content: '\f052';
-}
-.fa-chevron-left:before {
- content: '\f053';
-}
-.fa-chevron-right:before {
- content: '\f054';
-}
-.fa-plus-circle:before {
- content: '\f055';
-}
-.fa-minus-circle:before {
- content: '\f056';
-}
-.fa-times-circle:before {
- content: '\f057';
-}
-.fa-check-circle:before {
- content: '\f058';
-}
-.fa-question-circle:before {
- content: '\f059';
-}
-.fa-info-circle:before {
- content: '\f05a';
-}
-.fa-crosshairs:before {
- content: '\f05b';
-}
-.fa-times-circle-o:before {
- content: '\f05c';
-}
-.fa-check-circle-o:before {
- content: '\f05d';
-}
-.fa-ban:before {
- content: '\f05e';
-}
-.fa-arrow-left:before {
- content: '\f060';
-}
-.fa-arrow-right:before {
- content: '\f061';
-}
-.fa-arrow-up:before {
- content: '\f062';
-}
-.fa-arrow-down:before {
- content: '\f063';
-}
-.fa-mail-forward:before,
-.fa-share:before {
- content: '\f064';
-}
-.fa-expand:before {
- content: '\f065';
-}
-.fa-compress:before {
- content: '\f066';
-}
-.fa-plus:before {
- content: '\f067';
-}
-.fa-minus:before {
- content: '\f068';
-}
-.fa-asterisk:before {
- content: '\f069';
-}
-.fa-exclamation-circle:before {
- content: '\f06a';
-}
-.fa-gift:before {
- content: '\f06b';
-}
-.fa-leaf:before {
- content: '\f06c';
-}
-.fa-fire:before {
- content: '\f06d';
-}
-.fa-eye:before {
- content: '\f06e';
-}
-.fa-eye-slash:before {
- content: '\f070';
-}
-.fa-warning:before,
-.fa-exclamation-triangle:before {
- content: '\f071';
-}
-.fa-plane:before {
- content: '\f072';
-}
-.fa-calendar:before {
- content: '\f073';
-}
-.fa-random:before {
- content: '\f074';
-}
-.fa-comment:before {
- content: '\f075';
-}
-.fa-magnet:before {
- content: '\f076';
-}
-.fa-chevron-up:before {
- content: '\f077';
-}
-.fa-chevron-down:before {
- content: '\f078';
-}
-.fa-retweet:before {
- content: '\f079';
-}
-.fa-shopping-cart:before {
- content: '\f07a';
-}
-.fa-folder:before {
- content: '\f07b';
-}
-.fa-folder-open:before {
- content: '\f07c';
-}
-.fa-arrows-v:before {
- content: '\f07d';
-}
-.fa-arrows-h:before {
- content: '\f07e';
-}
-.fa-bar-chart-o:before,
-.fa-bar-chart:before {
- content: '\f080';
-}
-.fa-twitter-square:before {
- content: '\f081';
-}
-.fa-facebook-square:before {
- content: '\f082';
-}
-.fa-camera-retro:before {
- content: '\f083';
-}
-.fa-key:before {
- content: '\f084';
-}
-.fa-gears:before,
-.fa-cogs:before {
- content: '\f085';
-}
-.fa-comments:before {
- content: '\f086';
-}
-.fa-thumbs-o-up:before {
- content: '\f087';
-}
-.fa-thumbs-o-down:before {
- content: '\f088';
-}
-.fa-star-half:before {
- content: '\f089';
-}
-.fa-heart-o:before {
- content: '\f08a';
-}
-.fa-sign-out:before {
- content: '\f08b';
-}
-.fa-linkedin-square:before {
- content: '\f08c';
-}
-.fa-thumb-tack:before {
- content: '\f08d';
-}
-.fa-external-link:before {
- content: '\f08e';
-}
-.fa-sign-in:before {
- content: '\f090';
-}
-.fa-trophy:before {
- content: '\f091';
-}
-.fa-github-square:before {
- content: '\f092';
-}
-.fa-upload:before {
- content: '\f093';
-}
-.fa-lemon-o:before {
- content: '\f094';
-}
-.fa-phone:before {
- content: '\f095';
-}
-.fa-square-o:before {
- content: '\f096';
-}
-.fa-bookmark-o:before {
- content: '\f097';
-}
-.fa-phone-square:before {
- content: '\f098';
-}
-.fa-twitter:before {
- content: '\f099';
-}
-.fa-facebook-f:before,
-.fa-facebook:before {
- content: '\f09a';
-}
-.fa-github:before {
- content: '\f09b';
-}
-.fa-unlock:before {
- content: '\f09c';
-}
-.fa-credit-card:before {
- content: '\f09d';
-}
-.fa-rss:before {
- content: '\f09e';
-}
-.fa-hdd-o:before {
- content: '\f0a0';
-}
-.fa-bullhorn:before {
- content: '\f0a1';
-}
-.fa-bell:before {
- content: '\f0f3';
-}
-.fa-certificate:before {
- content: '\f0a3';
-}
-.fa-hand-o-right:before {
- content: '\f0a4';
-}
-.fa-hand-o-left:before {
- content: '\f0a5';
-}
-.fa-hand-o-up:before {
- content: '\f0a6';
-}
-.fa-hand-o-down:before {
- content: '\f0a7';
-}
-.fa-arrow-circle-left:before {
- content: '\f0a8';
-}
-.fa-arrow-circle-right:before {
- content: '\f0a9';
-}
-.fa-arrow-circle-up:before {
- content: '\f0aa';
-}
-.fa-arrow-circle-down:before {
- content: '\f0ab';
-}
-.fa-globe:before {
- content: '\f0ac';
-}
-.fa-wrench:before {
- content: '\f0ad';
-}
-.fa-tasks:before {
- content: '\f0ae';
-}
-.fa-filter:before {
- content: '\f0b0';
-}
-.fa-briefcase:before {
- content: '\f0b1';
-}
-.fa-arrows-alt:before {
- content: '\f0b2';
-}
-.fa-group:before,
-.fa-users:before {
- content: '\f0c0';
-}
-.fa-chain:before,
-.fa-link:before {
- content: '\f0c1';
-}
-.fa-cloud:before {
- content: '\f0c2';
-}
-.fa-flask:before {
- content: '\f0c3';
-}
-.fa-cut:before,
-.fa-scissors:before {
- content: '\f0c4';
-}
-.fa-copy:before,
-.fa-files-o:before {
- content: '\f0c5';
-}
-.fa-paperclip:before {
- content: '\f0c6';
-}
-.fa-save:before,
-.fa-floppy-o:before {
- content: '\f0c7';
-}
-.fa-square:before {
- content: '\f0c8';
-}
-.fa-navicon:before,
-.fa-reorder:before,
-.fa-bars:before {
- content: '\f0c9';
-}
-.fa-list-ul:before {
- content: '\f0ca';
-}
-.fa-list-ol:before {
- content: '\f0cb';
-}
-.fa-strikethrough:before {
- content: '\f0cc';
-}
-.fa-underline:before {
- content: '\f0cd';
-}
-.fa-table:before {
- content: '\f0ce';
-}
-.fa-magic:before {
- content: '\f0d0';
-}
-.fa-truck:before {
- content: '\f0d1';
-}
-.fa-pinterest:before {
- content: '\f0d2';
-}
-.fa-pinterest-square:before {
- content: '\f0d3';
-}
-.fa-google-plus-square:before {
- content: '\f0d4';
-}
-.fa-google-plus:before {
- content: '\f0d5';
-}
-.fa-money:before {
- content: '\f0d6';
-}
-.fa-caret-down:before {
- content: '\f0d7';
-}
-.fa-caret-up:before {
- content: '\f0d8';
-}
-.fa-caret-left:before {
- content: '\f0d9';
-}
-.fa-caret-right:before {
- content: '\f0da';
-}
-.fa-columns:before {
- content: '\f0db';
-}
-.fa-unsorted:before,
-.fa-sort:before {
- content: '\f0dc';
-}
-.fa-sort-down:before,
-.fa-sort-desc:before {
- content: '\f0dd';
-}
-.fa-sort-up:before,
-.fa-sort-asc:before {
- content: '\f0de';
-}
-.fa-envelope:before {
- content: '\f0e0';
-}
-.fa-linkedin:before {
- content: '\f0e1';
-}
-.fa-rotate-left:before,
-.fa-undo:before {
- content: '\f0e2';
-}
-.fa-legal:before,
-.fa-gavel:before {
- content: '\f0e3';
-}
-.fa-dashboard:before,
-.fa-tachometer:before {
- content: '\f0e4';
-}
-.fa-comment-o:before {
- content: '\f0e5';
-}
-.fa-comments-o:before {
- content: '\f0e6';
-}
-.fa-flash:before,
-.fa-bolt:before {
- content: '\f0e7';
-}
-.fa-sitemap:before {
- content: '\f0e8';
-}
-.fa-umbrella:before {
- content: '\f0e9';
-}
-.fa-paste:before,
-.fa-clipboard:before {
- content: '\f0ea';
-}
-.fa-lightbulb-o:before {
- content: '\f0eb';
-}
-.fa-exchange:before {
- content: '\f0ec';
-}
-.fa-cloud-download:before {
- content: '\f0ed';
-}
-.fa-cloud-upload:before {
- content: '\f0ee';
-}
-.fa-user-md:before {
- content: '\f0f0';
-}
-.fa-stethoscope:before {
- content: '\f0f1';
-}
-.fa-suitcase:before {
- content: '\f0f2';
-}
-.fa-bell-o:before {
- content: '\f0a2';
-}
-.fa-coffee:before {
- content: '\f0f4';
-}
-.fa-cutlery:before {
- content: '\f0f5';
-}
-.fa-file-text-o:before {
- content: '\f0f6';
-}
-.fa-building-o:before {
- content: '\f0f7';
-}
-.fa-hospital-o:before {
- content: '\f0f8';
-}
-.fa-ambulance:before {
- content: '\f0f9';
-}
-.fa-medkit:before {
- content: '\f0fa';
-}
-.fa-fighter-jet:before {
- content: '\f0fb';
-}
-.fa-beer:before {
- content: '\f0fc';
-}
-.fa-h-square:before {
- content: '\f0fd';
-}
-.fa-plus-square:before {
- content: '\f0fe';
-}
-.fa-angle-double-left:before {
- content: '\f100';
-}
-.fa-angle-double-right:before {
- content: '\f101';
-}
-.fa-angle-double-up:before {
- content: '\f102';
-}
-.fa-angle-double-down:before {
- content: '\f103';
-}
-.fa-angle-left:before {
- content: '\f104';
-}
-.fa-angle-right:before {
- content: '\f105';
-}
-.fa-angle-up:before {
- content: '\f106';
-}
-.fa-angle-down:before {
- content: '\f107';
-}
-.fa-desktop:before {
- content: '\f108';
-}
-.fa-laptop:before {
- content: '\f109';
-}
-.fa-tablet:before {
- content: '\f10a';
-}
-.fa-mobile-phone:before,
-.fa-mobile:before {
- content: '\f10b';
-}
-.fa-circle-o:before {
- content: '\f10c';
-}
-.fa-quote-left:before {
- content: '\f10d';
-}
-.fa-quote-right:before {
- content: '\f10e';
-}
-.fa-spinner:before {
- content: '\f110';
-}
-.fa-circle:before {
- content: '\f111';
-}
-.fa-mail-reply:before,
-.fa-reply:before {
- content: '\f112';
-}
-.fa-github-alt:before {
- content: '\f113';
-}
-.fa-folder-o:before {
- content: '\f114';
-}
-.fa-folder-open-o:before {
- content: '\f115';
-}
-.fa-smile-o:before {
- content: '\f118';
-}
-.fa-frown-o:before {
- content: '\f119';
-}
-.fa-meh-o:before {
- content: '\f11a';
-}
-.fa-gamepad:before {
- content: '\f11b';
-}
-.fa-keyboard-o:before {
- content: '\f11c';
-}
-.fa-flag-o:before {
- content: '\f11d';
-}
-.fa-flag-checkered:before {
- content: '\f11e';
-}
-.fa-terminal:before {
- content: '\f120';
-}
-.fa-code:before {
- content: '\f121';
-}
-.fa-mail-reply-all:before,
-.fa-reply-all:before {
- content: '\f122';
-}
-.fa-star-half-empty:before,
-.fa-star-half-full:before,
-.fa-star-half-o:before {
- content: '\f123';
-}
-.fa-location-arrow:before {
- content: '\f124';
-}
-.fa-crop:before {
- content: '\f125';
-}
-.fa-code-fork:before {
- content: '\f126';
-}
-.fa-unlink:before,
-.fa-chain-broken:before {
- content: '\f127';
-}
-.fa-question:before {
- content: '\f128';
-}
-.fa-info:before {
- content: '\f129';
-}
-.fa-exclamation:before {
- content: '\f12a';
-}
-.fa-superscript:before {
- content: '\f12b';
-}
-.fa-subscript:before {
- content: '\f12c';
-}
-.fa-eraser:before {
- content: '\f12d';
-}
-.fa-puzzle-piece:before {
- content: '\f12e';
-}
-.fa-microphone:before {
- content: '\f130';
-}
-.fa-microphone-slash:before {
- content: '\f131';
-}
-.fa-shield:before {
- content: '\f132';
-}
-.fa-calendar-o:before {
- content: '\f133';
-}
-.fa-fire-extinguisher:before {
- content: '\f134';
-}
-.fa-rocket:before {
- content: '\f135';
-}
-.fa-maxcdn:before {
- content: '\f136';
-}
-.fa-chevron-circle-left:before {
- content: '\f137';
-}
-.fa-chevron-circle-right:before {
- content: '\f138';
-}
-.fa-chevron-circle-up:before {
- content: '\f139';
-}
-.fa-chevron-circle-down:before {
- content: '\f13a';
-}
-.fa-html5:before {
- content: '\f13b';
-}
-.fa-css3:before {
- content: '\f13c';
-}
-.fa-anchor:before {
- content: '\f13d';
-}
-.fa-unlock-alt:before {
- content: '\f13e';
-}
-.fa-bullseye:before {
- content: '\f140';
-}
-.fa-ellipsis-h:before {
- content: '\f141';
-}
-.fa-ellipsis-v:before {
- content: '\f142';
-}
-.fa-rss-square:before {
- content: '\f143';
-}
-.fa-play-circle:before {
- content: '\f144';
-}
-.fa-ticket:before {
- content: '\f145';
-}
-.fa-minus-square:before {
- content: '\f146';
-}
-.fa-minus-square-o:before {
- content: '\f147';
-}
-.fa-level-up:before {
- content: '\f148';
-}
-.fa-level-down:before {
- content: '\f149';
-}
-.fa-check-square:before {
- content: '\f14a';
-}
-.fa-pencil-square:before {
- content: '\f14b';
-}
-.fa-external-link-square:before {
- content: '\f14c';
-}
-.fa-share-square:before {
- content: '\f14d';
-}
-.fa-compass:before {
- content: '\f14e';
-}
-.fa-toggle-down:before,
-.fa-caret-square-o-down:before {
- content: '\f150';
-}
-.fa-toggle-up:before,
-.fa-caret-square-o-up:before {
- content: '\f151';
-}
-.fa-toggle-right:before,
-.fa-caret-square-o-right:before {
- content: '\f152';
-}
-.fa-euro:before,
-.fa-eur:before {
- content: '\f153';
-}
-.fa-gbp:before {
- content: '\f154';
-}
-.fa-dollar:before,
-.fa-usd:before {
- content: '\f155';
-}
-.fa-rupee:before,
-.fa-inr:before {
- content: '\f156';
-}
-.fa-cny:before,
-.fa-rmb:before,
-.fa-yen:before,
-.fa-jpy:before {
- content: '\f157';
-}
-.fa-ruble:before,
-.fa-rouble:before,
-.fa-rub:before {
- content: '\f158';
-}
-.fa-won:before,
-.fa-krw:before {
- content: '\f159';
-}
-.fa-bitcoin:before,
-.fa-btc:before {
- content: '\f15a';
-}
-.fa-file:before {
- content: '\f15b';
-}
-.fa-file-text:before {
- content: '\f15c';
-}
-.fa-sort-alpha-asc:before {
- content: '\f15d';
-}
-.fa-sort-alpha-desc:before {
- content: '\f15e';
-}
-.fa-sort-amount-asc:before {
- content: '\f160';
-}
-.fa-sort-amount-desc:before {
- content: '\f161';
-}
-.fa-sort-numeric-asc:before {
- content: '\f162';
-}
-.fa-sort-numeric-desc:before {
- content: '\f163';
-}
-.fa-thumbs-up:before {
- content: '\f164';
-}
-.fa-thumbs-down:before {
- content: '\f165';
-}
-.fa-youtube-square:before {
- content: '\f166';
-}
-.fa-youtube:before {
- content: '\f167';
-}
-.fa-xing:before {
- content: '\f168';
-}
-.fa-xing-square:before {
- content: '\f169';
-}
-.fa-youtube-play:before {
- content: '\f16a';
-}
-.fa-dropbox:before {
- content: '\f16b';
-}
-.fa-stack-overflow:before {
- content: '\f16c';
-}
-.fa-instagram:before {
- content: '\f16d';
-}
-.fa-flickr:before {
- content: '\f16e';
-}
-.fa-adn:before {
- content: '\f170';
-}
-.fa-bitbucket:before {
- content: '\f171';
-}
-.fa-bitbucket-square:before {
- content: '\f172';
-}
-.fa-tumblr:before {
- content: '\f173';
-}
-.fa-tumblr-square:before {
- content: '\f174';
-}
-.fa-long-arrow-down:before {
- content: '\f175';
-}
-.fa-long-arrow-up:before {
- content: '\f176';
-}
-.fa-long-arrow-left:before {
- content: '\f177';
-}
-.fa-long-arrow-right:before {
- content: '\f178';
-}
-.fa-apple:before {
- content: '\f179';
-}
-.fa-windows:before {
- content: '\f17a';
-}
-.fa-android:before {
- content: '\f17b';
-}
-.fa-linux:before {
- content: '\f17c';
-}
-.fa-dribbble:before {
- content: '\f17d';
-}
-.fa-skype:before {
- content: '\f17e';
-}
-.fa-foursquare:before {
- content: '\f180';
-}
-.fa-trello:before {
- content: '\f181';
-}
-.fa-female:before {
- content: '\f182';
-}
-.fa-male:before {
- content: '\f183';
-}
-.fa-gittip:before,
-.fa-gratipay:before {
- content: '\f184';
-}
-.fa-sun-o:before {
- content: '\f185';
-}
-.fa-moon-o:before {
- content: '\f186';
-}
-.fa-archive:before {
- content: '\f187';
-}
-.fa-bug:before {
- content: '\f188';
-}
-.fa-vk:before {
- content: '\f189';
-}
-.fa-weibo:before {
- content: '\f18a';
-}
-.fa-renren:before {
- content: '\f18b';
-}
-.fa-pagelines:before {
- content: '\f18c';
-}
-.fa-stack-exchange:before {
- content: '\f18d';
-}
-.fa-arrow-circle-o-right:before {
- content: '\f18e';
-}
-.fa-arrow-circle-o-left:before {
- content: '\f190';
-}
-.fa-toggle-left:before,
-.fa-caret-square-o-left:before {
- content: '\f191';
-}
-.fa-dot-circle-o:before {
- content: '\f192';
-}
-.fa-wheelchair:before {
- content: '\f193';
-}
-.fa-vimeo-square:before {
- content: '\f194';
-}
-.fa-turkish-lira:before,
-.fa-try:before {
- content: '\f195';
-}
-.fa-plus-square-o:before {
- content: '\f196';
-}
-.fa-space-shuttle:before {
- content: '\f197';
-}
-.fa-slack:before {
- content: '\f198';
-}
-.fa-envelope-square:before {
- content: '\f199';
-}
-.fa-wordpress:before {
- content: '\f19a';
-}
-.fa-openid:before {
- content: '\f19b';
-}
-.fa-institution:before,
-.fa-bank:before,
-.fa-university:before {
- content: '\f19c';
-}
-.fa-mortar-board:before,
-.fa-graduation-cap:before {
- content: '\f19d';
-}
-.fa-yahoo:before {
- content: '\f19e';
-}
-.fa-google:before {
- content: '\f1a0';
-}
-.fa-reddit:before {
- content: '\f1a1';
-}
-.fa-reddit-square:before {
- content: '\f1a2';
-}
-.fa-stumbleupon-circle:before {
- content: '\f1a3';
-}
-.fa-stumbleupon:before {
- content: '\f1a4';
-}
-.fa-delicious:before {
- content: '\f1a5';
-}
-.fa-digg:before {
- content: '\f1a6';
-}
-.fa-pied-piper:before {
- content: '\f1a7';
-}
-.fa-pied-piper-alt:before {
- content: '\f1a8';
-}
-.fa-drupal:before {
- content: '\f1a9';
-}
-.fa-joomla:before {
- content: '\f1aa';
-}
-.fa-language:before {
- content: '\f1ab';
-}
-.fa-fax:before {
- content: '\f1ac';
-}
-.fa-building:before {
- content: '\f1ad';
-}
-.fa-child:before {
- content: '\f1ae';
-}
-.fa-paw:before {
- content: '\f1b0';
-}
-.fa-spoon:before {
- content: '\f1b1';
-}
-.fa-cube:before {
- content: '\f1b2';
-}
-.fa-cubes:before {
- content: '\f1b3';
-}
-.fa-behance:before {
- content: '\f1b4';
-}
-.fa-behance-square:before {
- content: '\f1b5';
-}
-.fa-steam:before {
- content: '\f1b6';
-}
-.fa-steam-square:before {
- content: '\f1b7';
-}
-.fa-recycle:before {
- content: '\f1b8';
-}
-.fa-automobile:before,
-.fa-car:before {
- content: '\f1b9';
-}
-.fa-cab:before,
-.fa-taxi:before {
- content: '\f1ba';
-}
-.fa-tree:before {
- content: '\f1bb';
-}
-.fa-spotify:before {
- content: '\f1bc';
-}
-.fa-deviantart:before {
- content: '\f1bd';
-}
-.fa-soundcloud:before {
- content: '\f1be';
-}
-.fa-database:before {
- content: '\f1c0';
-}
-.fa-file-pdf-o:before {
- content: '\f1c1';
-}
-.fa-file-word-o:before {
- content: '\f1c2';
-}
-.fa-file-excel-o:before {
- content: '\f1c3';
-}
-.fa-file-powerpoint-o:before {
- content: '\f1c4';
-}
-.fa-file-photo-o:before,
-.fa-file-picture-o:before,
-.fa-file-image-o:before {
- content: '\f1c5';
-}
-.fa-file-zip-o:before,
-.fa-file-archive-o:before {
- content: '\f1c6';
-}
-.fa-file-sound-o:before,
-.fa-file-audio-o:before {
- content: '\f1c7';
-}
-.fa-file-movie-o:before,
-.fa-file-video-o:before {
- content: '\f1c8';
-}
-.fa-file-code-o:before {
- content: '\f1c9';
-}
-.fa-vine:before {
- content: '\f1ca';
-}
-.fa-codepen:before {
- content: '\f1cb';
-}
-.fa-jsfiddle:before {
- content: '\f1cc';
-}
-.fa-life-bouy:before,
-.fa-life-buoy:before,
-.fa-life-saver:before,
-.fa-support:before,
-.fa-life-ring:before {
- content: '\f1cd';
-}
-.fa-circle-o-notch:before {
- content: '\f1ce';
-}
-.fa-ra:before,
-.fa-rebel:before {
- content: '\f1d0';
-}
-.fa-ge:before,
-.fa-empire:before {
- content: '\f1d1';
-}
-.fa-git-square:before {
- content: '\f1d2';
-}
-.fa-git:before {
- content: '\f1d3';
-}
-.fa-hacker-news:before {
- content: '\f1d4';
-}
-.fa-tencent-weibo:before {
- content: '\f1d5';
-}
-.fa-qq:before {
- content: '\f1d6';
-}
-.fa-wechat:before,
-.fa-weixin:before {
- content: '\f1d7';
-}
-.fa-send:before,
-.fa-paper-plane:before {
- content: '\f1d8';
-}
-.fa-send-o:before,
-.fa-paper-plane-o:before {
- content: '\f1d9';
-}
-.fa-history:before {
- content: '\f1da';
-}
-.fa-genderless:before,
-.fa-circle-thin:before {
- content: '\f1db';
-}
-.fa-header:before {
- content: '\f1dc';
-}
-.fa-paragraph:before {
- content: '\f1dd';
-}
-.fa-sliders:before {
- content: '\f1de';
-}
-.fa-share-alt:before {
- content: '\f1e0';
-}
-.fa-share-alt-square:before {
- content: '\f1e1';
-}
-.fa-bomb:before {
- content: '\f1e2';
-}
-.fa-soccer-ball-o:before,
-.fa-futbol-o:before {
- content: '\f1e3';
-}
-.fa-tty:before {
- content: '\f1e4';
-}
-.fa-binoculars:before {
- content: '\f1e5';
-}
-.fa-plug:before {
- content: '\f1e6';
-}
-.fa-slideshare:before {
- content: '\f1e7';
-}
-.fa-twitch:before {
- content: '\f1e8';
-}
-.fa-yelp:before {
- content: '\f1e9';
-}
-.fa-newspaper-o:before {
- content: '\f1ea';
-}
-.fa-wifi:before {
- content: '\f1eb';
-}
-.fa-calculator:before {
- content: '\f1ec';
-}
-.fa-paypal:before {
- content: '\f1ed';
-}
-.fa-google-wallet:before {
- content: '\f1ee';
-}
-.fa-cc-visa:before {
- content: '\f1f0';
-}
-.fa-cc-mastercard:before {
- content: '\f1f1';
-}
-.fa-cc-discover:before {
- content: '\f1f2';
-}
-.fa-cc-amex:before {
- content: '\f1f3';
-}
-.fa-cc-paypal:before {
- content: '\f1f4';
-}
-.fa-cc-stripe:before {
- content: '\f1f5';
-}
-.fa-bell-slash:before {
- content: '\f1f6';
-}
-.fa-bell-slash-o:before {
- content: '\f1f7';
-}
-.fa-trash:before {
- content: '\f1f8';
-}
-.fa-copyright:before {
- content: '\f1f9';
-}
-.fa-at:before {
- content: '\f1fa';
-}
-.fa-eyedropper:before {
- content: '\f1fb';
-}
-.fa-paint-brush:before {
- content: '\f1fc';
-}
-.fa-birthday-cake:before {
- content: '\f1fd';
-}
-.fa-area-chart:before {
- content: '\f1fe';
-}
-.fa-pie-chart:before {
- content: '\f200';
-}
-.fa-line-chart:before {
- content: '\f201';
-}
-.fa-lastfm:before {
- content: '\f202';
-}
-.fa-lastfm-square:before {
- content: '\f203';
-}
-.fa-toggle-off:before {
- content: '\f204';
-}
-.fa-toggle-on:before {
- content: '\f205';
-}
-.fa-bicycle:before {
- content: '\f206';
-}
-.fa-bus:before {
- content: '\f207';
-}
-.fa-ioxhost:before {
- content: '\f208';
-}
-.fa-angellist:before {
- content: '\f209';
-}
-.fa-cc:before {
- content: '\f20a';
-}
-.fa-shekel:before,
-.fa-sheqel:before,
-.fa-ils:before {
- content: '\f20b';
-}
-.fa-meanpath:before {
- content: '\f20c';
-}
-.fa-buysellads:before {
- content: '\f20d';
-}
-.fa-connectdevelop:before {
- content: '\f20e';
-}
-.fa-dashcube:before {
- content: '\f210';
-}
-.fa-forumbee:before {
- content: '\f211';
-}
-.fa-leanpub:before {
- content: '\f212';
-}
-.fa-sellsy:before {
- content: '\f213';
-}
-.fa-shirtsinbulk:before {
- content: '\f214';
-}
-.fa-simplybuilt:before {
- content: '\f215';
-}
-.fa-skyatlas:before {
- content: '\f216';
-}
-.fa-cart-plus:before {
- content: '\f217';
-}
-.fa-cart-arrow-down:before {
- content: '\f218';
-}
-.fa-diamond:before {
- content: '\f219';
-}
-.fa-ship:before {
- content: '\f21a';
-}
-.fa-user-secret:before {
- content: '\f21b';
-}
-.fa-motorcycle:before {
- content: '\f21c';
-}
-.fa-street-view:before {
- content: '\f21d';
-}
-.fa-heartbeat:before {
- content: '\f21e';
-}
-.fa-venus:before {
- content: '\f221';
-}
-.fa-mars:before {
- content: '\f222';
-}
-.fa-mercury:before {
- content: '\f223';
-}
-.fa-transgender:before {
- content: '\f224';
-}
-.fa-transgender-alt:before {
- content: '\f225';
-}
-.fa-venus-double:before {
- content: '\f226';
-}
-.fa-mars-double:before {
- content: '\f227';
-}
-.fa-venus-mars:before {
- content: '\f228';
-}
-.fa-mars-stroke:before {
- content: '\f229';
-}
-.fa-mars-stroke-v:before {
- content: '\f22a';
-}
-.fa-mars-stroke-h:before {
- content: '\f22b';
-}
-.fa-neuter:before {
- content: '\f22c';
-}
-.fa-facebook-official:before {
- content: '\f230';
-}
-.fa-pinterest-p:before {
- content: '\f231';
-}
-.fa-whatsapp:before {
- content: '\f232';
-}
-.fa-server:before {
- content: '\f233';
-}
-.fa-user-plus:before {
- content: '\f234';
-}
-.fa-user-times:before {
- content: '\f235';
-}
-.fa-hotel:before,
-.fa-bed:before {
- content: '\f236';
-}
-.fa-viacoin:before {
- content: '\f237';
-}
-.fa-train:before {
- content: '\f238';
-}
-.fa-subway:before {
- content: '\f239';
-}
-.fa-medium:before {
- content: '\f23a';
-}
diff --git a/webapp/sass/vendors/_module.scss b/webapp/sass/vendors/_module.scss
deleted file mode 100644
index ed8a124a2..000000000
--- a/webapp/sass/vendors/_module.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-// Only for combining all the files in this folder
-@import 'perfect-scrollbar';
-@import 'font-awesome';
-@import 'colorpicker';
diff --git a/webapp/sass/vendors/_perfect-scrollbar.scss b/webapp/sass/vendors/_perfect-scrollbar.scss
deleted file mode 100755
index 212a22687..000000000
--- a/webapp/sass/vendors/_perfect-scrollbar.scss
+++ /dev/null
@@ -1,141 +0,0 @@
-@charset 'UTF-8';
-
-.ps-container {
- overflow: hidden !important;
-}
-.ps-container.ps-active-x > .ps-scrollbar-x-rail,
-.ps-container.ps-active-y > .ps-scrollbar-y-rail {
- display: block;
-}
-
-.ps-container.ps-in-scrolling.ps-x > .ps-scrollbar-x-rail {
- background-color: #eee;
- opacity: .9;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=90)';
- filter: alpha(opacity=90);
-}
-.ps-container.ps-in-scrolling.ps-x > .ps-scrollbar-x-rail > .ps-scrollbar-x {
- background-color: #999;
-}
-.ps-container.ps-in-scrolling.ps-y > .ps-scrollbar-y-rail {
- background-color: #eee;
- opacity: .9;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=90)';
- filter: alpha(opacity=90);
-}
-.ps-container.ps-in-scrolling.ps-y > .ps-scrollbar-y-rail > .ps-scrollbar-y {
- background-color: #999;
-}
-.ps-container > .ps-scrollbar-x-rail {
- display: none;
- position: absolute;
- /* please don't change 'position' */
- -webkit-border-radius: 4px;
- -moz-border-radius: 4px;
- -ms-border-radius: 4px;
- border-radius: 4px;
- opacity: 0;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)';
- filter: alpha(opacity=0);
- -webkit-transition: background-color .2s linear, opacity .2s linear;
- -moz-transition: background-color .2s linear, opacity .2s linear;
- -o-transition: background-color .2s linear, opacity .2s linear;
- transition: background-color .2s linear, opacity .2s linear;
- bottom: 3px;
- /* there must be 'bottom' for ps-scrollbar-x-rail */
- height: 8px;
-}
-.ps-container > .ps-scrollbar-x-rail > .ps-scrollbar-x {
- position: absolute;
- /* please don't change 'position' */
- background-color: #aaa;
- -webkit-border-radius: 4px;
- -moz-border-radius: 4px;
- -ms-border-radius: 4px;
- border-radius: 4px;
- -webkit-transition: background-color .2s linear;
- -moz-transition: background-color .2s linear;
- -o-transition: background-color .2s linear;
- transition: background-color .2s linear;
- bottom: 0;
- /* there must be 'bottom' for ps-scrollbar-x */
- height: 8px;
-}
-.ps-container > .ps-scrollbar-y-rail {
- display: none;
- position: absolute;
- /* please don't change 'position' */
- -webkit-border-radius: 4px;
- -moz-border-radius: 4px;
- -ms-border-radius: 4px;
- border-radius: 4px;
- opacity: 0;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)';
- filter: alpha(opacity=0);
- -webkit-transition: background-color .2s linear, opacity .2s linear;
- -moz-transition: background-color .2s linear, opacity .2s linear;
- -o-transition: background-color .2s linear, opacity .2s linear;
- transition: background-color .2s linear, opacity .2s linear;
- right: 3px;
- /* there must be 'right' for ps-scrollbar-y-rail */
- width: 8px;
-}
-.ps-container > .ps-scrollbar-y-rail > .ps-scrollbar-y {
- position: absolute;
- /* please don't change 'position' */
- background-color: #aaa;
- -webkit-border-radius: 4px;
- -moz-border-radius: 4px;
- -ms-border-radius: 4px;
- border-radius: 4px;
- -webkit-transition: background-color .2s linear;
- -moz-transition: background-color .2s linear;
- -o-transition: background-color .2s linear;
- transition: background-color .2s linear;
- right: 0;
- /* there must be 'right' for ps-scrollbar-y */
- width: 8px;
-}
-
-.ps-container:hover.ps-in-scrolling.ps-x > .ps-scrollbar-x-rail {
- background-color: #eee;
- opacity: .9;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=90)';
- filter: alpha(opacity=90);
-}
-.ps-container:hover.ps-in-scrolling.ps-x > .ps-scrollbar-x-rail > .ps-scrollbar-x {
- background-color: #999;
-}
-.ps-container:hover.ps-in-scrolling.ps-y > .ps-scrollbar-y-rail {
- background-color: #eee;
- opacity: .9;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=90)';
- filter: alpha(opacity=90);
-}
-.ps-container:hover.ps-in-scrolling.ps-y > .ps-scrollbar-y-rail > .ps-scrollbar-y {
- background-color: #999;
-}
-.ps-container:hover > .ps-scrollbar-x-rail,
-.ps-container:hover > .ps-scrollbar-y-rail {
- opacity: .6;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=60)';
- filter: alpha(opacity=60);
-}
-.ps-container:hover > .ps-scrollbar-x-rail:hover {
- background-color: #eee;
- opacity: .9;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=90)';
- filter: alpha(opacity=90);
-}
-.ps-container:hover > .ps-scrollbar-x-rail:hover > .ps-scrollbar-x {
- background-color: #999;
-}
-.ps-container:hover > .ps-scrollbar-y-rail:hover {
- background-color: #eee;
- opacity: .9;
- -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=90)';
- filter: alpha(opacity=90);
-}
-.ps-container:hover > .ps-scrollbar-y-rail:hover > .ps-scrollbar-y {
- background-color: #999;
-}
diff --git a/webapp/stores/browser_store.jsx b/webapp/stores/browser_store.jsx
index bba146e38..d605aac80 100644
--- a/webapp/stores/browser_store.jsx
+++ b/webapp/stores/browser_store.jsx
@@ -8,7 +8,7 @@ function getPrefix() {
return global.window.mm_current_user_id + '_';
}
- console.log('BrowserStore tried to operate without user present'); //eslint-disable-line no-console
+ console.warn('BrowserStore tried to operate without user present'); //eslint-disable-line no-console
return 'unknown_';
}
@@ -144,18 +144,14 @@ class BrowserStoreClass {
* Signature for action is action(key, value)
*/
actionOnGlobalItemsWithPrefix(prefix, action) {
- var globalPrefix = getPrefix();
- var globalPrefixiLen = globalPrefix.length;
-
var storage = sessionStorage;
if (this.isLocalStorageSupported()) {
storage = localStorage;
}
for (var key in storage) {
- if (key.lastIndexOf(globalPrefix + prefix, 0) === 0) {
- var userkey = key.substring(globalPrefixiLen);
- action(userkey, this.getGlobalItem(key));
+ if (key.lastIndexOf(prefix, 0) === 0) {
+ action(key, this.getGlobalItem(key));
}
}
}
diff --git a/webapp/stores/error_store.jsx b/webapp/stores/error_store.jsx
index 7c695a335..715029185 100644
--- a/webapp/stores/error_store.jsx
+++ b/webapp/stores/error_store.jsx
@@ -59,6 +59,7 @@ class ErrorStoreClass extends EventEmitter {
clearLastError() {
BrowserStore.removeGlobalItem('last_error');
BrowserStore.removeGlobalItem('last_error_conn');
+ this.emitChange();
}
}
diff --git a/webapp/stores/file_store.jsx b/webapp/stores/file_store.jsx
index 2628685cc..2692e6959 100644
--- a/webapp/stores/file_store.jsx
+++ b/webapp/stores/file_store.jsx
@@ -13,10 +13,6 @@ class FileStore extends EventEmitter {
constructor() {
super();
- this.addChangeListener = this.addChangeListener.bind(this);
- this.removeChangeListener = this.removeChangeListener.bind(this);
- this.emitChange = this.emitChange.bind(this);
-
this.handleEventPayload = this.handleEventPayload.bind(this);
this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
diff --git a/webapp/stores/integration_store.jsx b/webapp/stores/integration_store.jsx
new file mode 100644
index 000000000..abd7e3558
--- /dev/null
+++ b/webapp/stores/integration_store.jsx
@@ -0,0 +1,134 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
+import Constants from 'utils/constants.jsx';
+import EventEmitter from 'events';
+
+const ActionTypes = Constants.ActionTypes;
+
+const CHANGE_EVENT = 'changed';
+
+class IntegrationStore extends EventEmitter {
+ constructor() {
+ super();
+
+ this.dispatchToken = AppDispatcher.register(this.handleEventPayload.bind(this));
+
+ this.incomingWebhooks = [];
+ this.receivedIncomingWebhooks = false;
+
+ this.outgoingWebhooks = [];
+ this.receivedOutgoingWebhooks = false;
+ }
+
+ addChangeListener(callback) {
+ this.on(CHANGE_EVENT, callback);
+ }
+
+ removeChangeListener(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ }
+
+ emitChange() {
+ this.emit(CHANGE_EVENT);
+ }
+
+ hasReceivedIncomingWebhooks() {
+ return this.receivedIncomingWebhooks;
+ }
+
+ getIncomingWebhooks() {
+ return this.incomingWebhooks;
+ }
+
+ setIncomingWebhooks(incomingWebhooks) {
+ this.incomingWebhooks = incomingWebhooks;
+ this.receivedIncomingWebhooks = true;
+ }
+
+ addIncomingWebhook(incomingWebhook) {
+ this.incomingWebhooks.push(incomingWebhook);
+ }
+
+ removeIncomingWebhook(id) {
+ for (let i = 0; i < this.incomingWebhooks.length; i++) {
+ if (this.incomingWebhooks[i].id === id) {
+ this.incomingWebhooks.splice(i, 1);
+ break;
+ }
+ }
+ }
+
+ hasReceivedOutgoingWebhooks() {
+ return this.receivedIncomingWebhooks;
+ }
+
+ getOutgoingWebhooks() {
+ return this.outgoingWebhooks;
+ }
+
+ setOutgoingWebhooks(outgoingWebhooks) {
+ this.outgoingWebhooks = outgoingWebhooks;
+ this.receivedOutgoingWebhooks = true;
+ }
+
+ addOutgoingWebhook(outgoingWebhook) {
+ this.outgoingWebhooks.push(outgoingWebhook);
+ }
+
+ updateOutgoingWebhook(outgoingWebhook) {
+ for (let i = 0; i < this.outgoingWebhooks.length; i++) {
+ if (this.outgoingWebhooks[i].id === outgoingWebhook.id) {
+ this.outgoingWebhooks[i] = outgoingWebhook;
+ break;
+ }
+ }
+ }
+
+ removeOutgoingWebhook(id) {
+ for (let i = 0; i < this.outgoingWebhooks.length; i++) {
+ if (this.outgoingWebhooks[i].id === id) {
+ this.outgoingWebhooks.splice(i, 1);
+ break;
+ }
+ }
+ }
+
+ handleEventPayload(payload) {
+ const action = payload.action;
+
+ switch (action.type) {
+ case ActionTypes.RECEIVED_INCOMING_WEBHOOKS:
+ this.setIncomingWebhooks(action.incomingWebhooks);
+ this.emitChange();
+ break;
+ case ActionTypes.RECEIVED_INCOMING_WEBHOOK:
+ this.addIncomingWebhook(action.incomingWebhook);
+ this.emitChange();
+ break;
+ case ActionTypes.REMOVED_INCOMING_WEBHOOK:
+ this.removeIncomingWebhook(action.id);
+ this.emitChange();
+ break;
+ case ActionTypes.RECEIVED_OUTGOING_WEBHOOKS:
+ this.setOutgoingWebhooks(action.outgoingWebhooks);
+ this.emitChange();
+ break;
+ case ActionTypes.RECEIVED_OUTGOING_WEBHOOK:
+ this.addOutgoingWebhook(action.outgoingWebhook);
+ this.emitChange();
+ break;
+ case ActionTypes.UPDATED_OUTGOING_WEBHOOK:
+ this.updateOutgoingWebhook(action.outgoingWebhook);
+ this.emitChange();
+ break;
+ case ActionTypes.REMOVED_OUTGOING_WEBHOOK:
+ this.removeOutgoingWebhook(action.id);
+ this.emitChange();
+ break;
+ }
+ }
+}
+
+export default new IntegrationStore();
diff --git a/webapp/stores/notificaiton_store.jsx b/webapp/stores/notification_store.jsx
index 70caffeb6..6722af281 100644
--- a/webapp/stores/notificaiton_store.jsx
+++ b/webapp/stores/notification_store.jsx
@@ -89,7 +89,7 @@ NotificationStore.dispatchToken = AppDispatcher.register((payload) => {
switch (action.type) {
case ActionTypes.RECEIVED_POST:
- NotificationStore.handleRecievedPost(action.post, action.webspcketMessageProps);
+ NotificationStore.handleRecievedPost(action.post, action.websocketMessageProps);
NotificationStore.emitChange();
break;
}
diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx
index 903085760..3f2f75796 100644
--- a/webapp/stores/post_store.jsx
+++ b/webapp/stores/post_store.jsx
@@ -96,7 +96,7 @@ class PostStoreClass extends EventEmitter {
let post = null;
if (posts.posts.hasOwnProperty(postId)) {
- post = Object.assign({}, posts.posts[postId]);
+ post = posts.posts[postId];
}
return post;
@@ -104,7 +104,7 @@ class PostStoreClass extends EventEmitter {
getAllPosts(id) {
if (this.postsInfo.hasOwnProperty(id)) {
- return Object.assign({}, this.postsInfo[id].postList);
+ return this.postsInfo[id].postList;
}
return null;
@@ -406,7 +406,7 @@ class PostStoreClass extends EventEmitter {
let posts;
let pendingPosts;
for (const k in this.postsInfo) {
- if (this.postsInfo[k].postList.posts.hasOwnProperty(this.selectedPostId)) {
+ if (this.postsInfo[k].postList && this.postsInfo[k].postList.posts.hasOwnProperty(this.selectedPostId)) {
posts = this.postsInfo[k].postList.posts;
if (this.postsInfo[k].pendingPosts != null) {
pendingPosts = this.postsInfo[k].pendingPosts.posts;
@@ -495,7 +495,7 @@ class PostStoreClass extends EventEmitter {
BrowserStore.actionOnGlobalItemsWithPrefix('draft_', (key, value) => {
if (value) {
value.uploadsInProgress = [];
- BrowserStore.setItem(key, value);
+ BrowserStore.setGlobalItem(key, value);
}
});
}
@@ -503,7 +503,7 @@ class PostStoreClass extends EventEmitter {
BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', (key, value) => {
if (value) {
value.uploadsInProgress = [];
- BrowserStore.setItem(key, value);
+ BrowserStore.setGlobalItem(key, value);
}
});
}
@@ -531,8 +531,8 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => {
switch (action.type) {
case ActionTypes.RECEIVED_POSTS: {
const id = PostStore.currentFocusedPostId == null ? action.id : PostStore.currentFocusedPostId;
- PostStore.checkBounds(id, action.numRequested, makePostListNonNull(action.post_list), action.before);
PostStore.storePosts(id, makePostListNonNull(action.post_list));
+ PostStore.checkBounds(id, action.numRequested, makePostListNonNull(action.post_list), action.before);
PostStore.emitChange();
break;
}
diff --git a/webapp/stores/search_store.jsx b/webapp/stores/search_store.jsx
index acaa9e52f..dc08ca3a6 100644
--- a/webapp/stores/search_store.jsx
+++ b/webapp/stores/search_store.jsx
@@ -16,7 +16,7 @@ class SearchStoreClass extends EventEmitter {
constructor() {
super();
- this.searchResults = {};
+ this.searchResults = null;
this.isMentionSearch = false;
this.searchTerm = '';
}
diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx
index 6140fd9e0..cc19baa7e 100644
--- a/webapp/utils/async_client.jsx
+++ b/webapp/utils/async_client.jsx
@@ -1121,3 +1121,140 @@ export function getRecentAndNewUsersAnalytics(teamId) {
}
);
}
+
+export function listIncomingHooks() {
+ if (isCallInProgress('listIncomingHooks')) {
+ return;
+ }
+
+ callTracker.listIncomingHooks = utils.getTimestamp();
+
+ client.listIncomingHooks(
+ (data) => {
+ callTracker.listIncomingHooks = 0;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_INCOMING_WEBHOOKS,
+ incomingWebhooks: data
+ });
+ },
+ (err) => {
+ callTracker.listIncomingHooks = 0;
+ dispatchError(err, 'getIncomingHooks');
+ }
+ );
+}
+
+export function listOutgoingHooks() {
+ if (isCallInProgress('listOutgoingHooks')) {
+ return;
+ }
+
+ callTracker.listOutgoingHooks = utils.getTimestamp();
+
+ client.listOutgoingHooks(
+ (data) => {
+ callTracker.listOutgoingHooks = 0;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_OUTGOING_WEBHOOKS,
+ outgoingWebhooks: data
+ });
+ },
+ (err) => {
+ callTracker.listOutgoingHooks = 0;
+ dispatchError(err, 'getOutgoingHooks');
+ }
+ );
+}
+
+export function addIncomingHook(hook, success, error) {
+ client.addIncomingHook(
+ hook,
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_INCOMING_WEBHOOK,
+ incomingWebhook: data
+ });
+
+ if (success) {
+ success();
+ }
+ },
+ (err) => {
+ dispatchError(err, 'addIncomingHook');
+
+ if (error) {
+ error(err);
+ }
+ }
+ );
+}
+
+export function addOutgoingHook(hook, success, error) {
+ client.addOutgoingHook(
+ hook,
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_OUTGOING_WEBHOOK,
+ outgoingWebhook: data
+ });
+
+ if (success) {
+ success();
+ }
+ },
+ (err) => {
+ dispatchError(err, 'addOutgoingHook');
+
+ if (error) {
+ error(err);
+ }
+ }
+ );
+}
+
+export function deleteIncomingHook(id) {
+ client.deleteIncomingHook(
+ {id},
+ () => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.REMOVED_INCOMING_WEBHOOK,
+ id
+ });
+ },
+ (err) => {
+ dispatchError(err, 'deleteIncomingHook');
+ }
+ );
+}
+
+export function deleteOutgoingHook(id) {
+ client.deleteOutgoingHook(
+ {id},
+ () => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.REMOVED_OUTGOING_WEBHOOK,
+ id
+ });
+ },
+ (err) => {
+ dispatchError(err, 'deleteOutgoingHook');
+ }
+ );
+}
+
+export function regenOutgoingHookToken(id) {
+ client.regenOutgoingHookToken(
+ {id},
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.UPDATED_OUTGOING_WEBHOOK,
+ outgoingWebhook: data
+ });
+ },
+ (err) => {
+ dispatchError(err, 'regenOutgoingHookToken');
+ }
+ );
+}
diff --git a/webapp/utils/client.jsx b/webapp/utils/client.jsx
index d42767d31..854aa31dc 100644
--- a/webapp/utils/client.jsx
+++ b/webapp/utils/client.jsx
@@ -50,12 +50,8 @@ function handleError(methodName, xhr, status, err) {
track('api', 'api_weberror', methodName, 'message', msg);
if (xhr.status === 401) {
- if (window.location.href.indexOf('/channels') === 0) {
- browserHistory.push('/login?extra=expired&redirect=' + encodeURIComponent(window.location.pathname + window.location.search));
- } else {
- var teamURL = window.location.pathname.split('/channels')[0];
- browserHistory.push(teamURL + '/login?extra=expired&redirect=' + encodeURIComponent(window.location.pathname + window.location.search));
- }
+ const team = window.location.pathname.split('/')[1];
+ browserHistory.push('/' + team + '/login?extra=expired&redirect=' + encodeURIComponent(window.location.pathname + window.location.search));
}
return e;
@@ -337,13 +333,28 @@ export function logout(success, error) {
});
}
-export function loginByEmail(name, email, password, success, error) {
+export function checkMfa(method, team, loginId, success, error) {
+ $.ajax({
+ url: '/api/v1/users/mfa',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify({method, team_name: team, login_id: loginId}),
+ success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('checkMfa', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
+export function loginByEmail(name, email, password, token, success, error) {
$.ajax({
url: '/api/v1/users/login',
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- data: JSON.stringify({name, email, password}),
+ data: JSON.stringify({name, email, password, token}),
success: function onSuccess(data, textStatus, xhr) {
track('api', 'api_users_login_success', data.team_id, 'email', data.email);
sessionStorage.removeItem(data.id + '_last_error');
@@ -381,13 +392,13 @@ export function loginByUsername(name, username, password, success, error) {
});
}
-export function loginByLdap(teamName, id, password, success, error) {
+export function loginByLdap(teamName, id, password, token, success, error) {
$.ajax({
url: '/api/v1/users/login_ldap',
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- data: JSON.stringify({teamName, id, password}),
+ data: JSON.stringify({teamName, id, password, token}),
success: function onSuccess(data, textStatus, xhr) {
track('api', 'api_users_loginLdap_success', data.team_id, 'id', id);
sessionStorage.removeItem(data.id + '_last_error');
@@ -1712,3 +1723,18 @@ export function resendVerification(success, error, teamName, email) {
}
});
}
+
+export function updateMfa(data, success, error) {
+ $.ajax({
+ url: '/api/v1/users/update_mfa',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('updateMfa', xhr, status, err);
+ error(e);
+ }
+ });
+}
diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx
index bcd2fadb9..d01163b31 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -14,6 +14,7 @@ import patchIcon from 'images/icons/patch.png';
import genericIcon from 'images/icons/generic.png';
import logoImage from 'images/logo_compact.png';
+import logoWebhook from 'images/webhook_icon.jpg';
import solarizedDarkCSS from '!!file?name=files/code_themes/[hash].[ext]!highlight.js/styles/solarized-dark.css';
import solarizedDarkIcon from 'images/themes/code_themes/solarized-dark.png';
@@ -68,6 +69,15 @@ export default {
RECEIVED_PREFERENCE: null,
RECEIVED_PREFERENCES: null,
RECEIVED_FILE_INFO: null,
+ RECEIVED_ANALYTICS: null,
+
+ RECEIVED_INCOMING_WEBHOOKS: null,
+ RECEIVED_INCOMING_WEBHOOK: null,
+ REMOVED_INCOMING_WEBHOOK: null,
+ RECEIVED_OUTGOING_WEBHOOKS: null,
+ RECEIVED_OUTGOING_WEBHOOK: null,
+ UPDATED_OUTGOING_WEBHOOK: null,
+ REMOVED_OUTGOING_WEBHOOK: null,
RECEIVED_MSG: null,
@@ -182,11 +192,14 @@ export default {
MOBILE_VIDEO_WIDTH: 480,
MOBILE_VIDEO_HEIGHT: 360,
DEFAULT_CHANNEL: 'town-square',
+ DEFAULT_CHANNEL_UI_NAME: 'Town Square',
OFFTOPIC_CHANNEL: 'off-topic',
+ OFFTOPIC_CHANNEL_UI_NAME: 'Off-Topic',
GITLAB_SERVICE: 'gitlab',
GOOGLE_SERVICE: 'google',
- LDAP_SERVICE: 'ldap',
EMAIL_SERVICE: 'email',
+ LDAP_SERVICE: 'ldap',
+ USERNAME_SERVICE: 'username',
SIGNIN_CHANGE: 'signin_change',
SIGNIN_VERIFIED: 'verified',
SESSION_EXPIRED: 'expired',
@@ -235,6 +248,7 @@ export default {
OPEN_TEAM: 'O',
MAX_POST_LEN: 4000,
EMOJI_SIZE: 16,
+ MATTERMOST_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'viewBox='0 0 500 500' style='enable-background:new 0 0 500 500;' xml:space='preserve'> <style type='text/css'> .st0{fill-rule:evenodd;clip-rule:evenodd;fill:#222222;} </style> <g id='XMLID_1_'> <g id='XMLID_3_'> <path id='XMLID_4_' class='st0' d='M396.9,47.7l2.6,53.1c43,47.5,60,114.8,38.6,178.1c-32,94.4-137.4,144.1-235.4,110.9 S51.1,253.1,83,158.7C104.5,95.2,159.2,52,222.5,40.5l34.2-40.4C150-2.8,49.3,63.4,13.3,169.9C-31,300.6,39.1,442.5,169.9,486.7 s272.6-25.8,316.9-156.6C522.7,223.9,483.1,110.3,396.9,47.7z'/> </g> <path id='XMLID_2_' class='st0' d='M335.6,204.3l-1.8-74.2l-1.5-42.7l-1-37c0,0,0.2-17.8-0.4-22c-0.1-0.9-0.4-1.6-0.7-2.2 c0-0.1-0.1-0.2-0.1-0.3c0-0.1-0.1-0.2-0.1-0.2c-0.7-1.2-1.8-2.1-3.1-2.6c-1.4-0.5-2.9-0.4-4.2,0.2c0,0-0.1,0-0.1,0 c-0.2,0.1-0.3,0.1-0.4,0.2c-0.6,0.3-1.2,0.7-1.8,1.3c-3,3-13.7,17.2-13.7,17.2l-23.2,28.8l-27.1,33l-46.5,57.8 c0,0-21.3,26.6-16.6,59.4s29.1,48.7,48,55.1c18.9,6.4,48,8.5,71.6-14.7C336.4,238.4,335.6,204.3,335.6,204.3z'/> </g> </svg>",
ONLINE_ICON_SVG: "<svg version='1.1'id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:cc='http://creativecommons.org/ns#' inkscape:version='0.48.4 r9939' sodipodi:docname='TRASH_1_4.svg'xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='-243 245 12 12'style='enable-background:new -243 245 12 12;' xml:space='preserve'> <sodipodi:namedview inkscape:cx='26.358185' inkscape:zoom='1.18' bordercolor='#666666' pagecolor='#ffffff' borderopacity='1' objecttolerance='10' inkscape:cy='139.7898' gridtolerance='10' guidetolerance='10' showgrid='false' showguides='true' id='namedview6' inkscape:pageopacity='0' inkscape:pageshadow='2' inkscape:guide-bbox='true' inkscape:window-width='1366' inkscape:current-layer='Layer_1' inkscape:window-height='705' inkscape:window-y='-8' inkscape:window-maximized='1' inkscape:window-x='-8'> <sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide> <sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide> </sodipodi:namedview> <g> <path class='online--icon' d='M-236,250.5C-236,250.5-236,250.5-236,250.5C-236,250.5-236,250.5-236,250.5C-236,250.5-236,250.5-236,250.5z'/> <ellipse class='online--icon' cx='-238.5' cy='248' rx='2.5' ry='2.5'/> </g> <path class='online--icon' d='M-238.9,253.8c0-0.4,0.1-0.9,0.2-1.3c-2.2-0.2-2.2-2-2.2-2s-1,0.1-1.2,0.5c-0.4,0.6-0.6,1.7-0.7,2.5c0,0.1-0.1,0.5,0,0.6 c0.2,1.3,2.2,2.3,4.4,2.4c0,0,0.1,0,0.1,0c0,0,0.1,0,0.1,0c0,0,0.1,0,0.1,0C-238.7,255.7-238.9,254.8-238.9,253.8z'/> <g> <g> <path class='online--icon' d='M-232.3,250.1l1.3,1.3c0,0,0,0.1,0,0.1l-4.1,4.1c0,0,0,0-0.1,0c0,0,0,0,0,0l-2.7-2.7c0,0,0-0.1,0-0.1l1.2-1.2 c0,0,0.1,0,0.1,0l1.4,1.4l2.9-2.9C-232.4,250.1-232.3,250.1-232.3,250.1z'/> </g> </g> </svg>",
AWAY_ICON_SVG: "<svg version='1.1'id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:cc='http://creativecommons.org/ns#' inkscape:version='0.48.4 r9939' sodipodi:docname='TRASH_1_4.svg'xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='-299 391 12 12'style='enable-background:new -299 391 12 12;' xml:space='preserve'> <sodipodi:namedview inkscape:cx='26.358185' inkscape:zoom='1.18' bordercolor='#666666' pagecolor='#ffffff' borderopacity='1' objecttolerance='10' inkscape:cy='139.7898' gridtolerance='10' guidetolerance='10' showgrid='false' showguides='true' id='namedview6' inkscape:pageopacity='0' inkscape:pageshadow='2' inkscape:guide-bbox='true' inkscape:window-width='1366' inkscape:current-layer='Layer_1' inkscape:window-height='705' inkscape:window-y='-8' inkscape:window-maximized='1' inkscape:window-x='-8'> <sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide> <sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide> </sodipodi:namedview> <g> <ellipse class='away--icon' cx='-294.6' cy='394' rx='2.5' ry='2.5'/> <path class='away--icon' d='M-293.8,399.4c0-0.4,0.1-0.7,0.2-1c-0.3,0.1-0.6,0.2-1,0.2c-2.5,0-2.5-2-2.5-2s-1,0.1-1.2,0.5c-0.4,0.6-0.6,1.7-0.7,2.5 c0,0.1-0.1,0.5,0,0.6c0.2,1.3,2.2,2.3,4.4,2.4c0,0,0.1,0,0.1,0c0,0,0.1,0,0.1,0c0.7,0,1.4-0.1,2-0.3 C-293.3,401.5-293.8,400.5-293.8,399.4z'/> </g> <path class='away--icon' d='M-287,400c0,0.1-0.1,0.1-0.1,0.1l-4.9,0c-0.1,0-0.1-0.1-0.1-0.1v-1.6c0-0.1,0.1-0.1,0.1-0.1l4.9,0c0.1,0,0.1,0.1,0.1,0.1 V400z'/> </svg>",
OFFLINE_ICON_SVG: "<svg version='1.1'id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:cc='http://creativecommons.org/ns#' inkscape:version='0.48.4 r9939' sodipodi:docname='TRASH_1_4.svg'xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='-299 391 12 12'style='enable-background:new -299 391 12 12;' xml:space='preserve'> <sodipodi:namedview inkscape:cx='26.358185' inkscape:zoom='1.18' bordercolor='#666666' pagecolor='#ffffff' borderopacity='1' objecttolerance='10' inkscape:cy='139.7898' gridtolerance='10' guidetolerance='10' showgrid='false' showguides='true' id='namedview6' inkscape:pageopacity='0' inkscape:pageshadow='2' inkscape:guide-bbox='true' inkscape:window-width='1366' inkscape:current-layer='Layer_1' inkscape:window-height='705' inkscape:window-y='-8' inkscape:window-maximized='1' inkscape:window-x='-8'> <sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide> <sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide> </sodipodi:namedview> <g> <g> <ellipse class='offline--icon' cx='-294.5' cy='394' rx='2.5' ry='2.5'/> <path class='offline--icon' d='M-294.3,399.7c0-0.4,0.1-0.8,0.2-1.2c-0.1,0-0.2,0-0.4,0c-2.5,0-2.5-2-2.5-2s-1,0.1-1.2,0.5c-0.4,0.6-0.6,1.7-0.7,2.5 c0,0.1-0.1,0.5,0,0.6c0.2,1.3,2.2,2.3,4.4,2.4h0.1h0.1c0.3,0,0.7,0,1-0.1C-293.9,401.6-294.3,400.7-294.3,399.7z'/> </g> </g> <g> <path class='offline--icon' d='M-288.9,399.4l1.8-1.8c0.1-0.1,0.1-0.3,0-0.3l-0.7-0.7c-0.1-0.1-0.3-0.1-0.3,0l-1.8,1.8l-1.8-1.8c-0.1-0.1-0.3-0.1-0.3,0 l-0.7,0.7c-0.1,0.1-0.1,0.3,0,0.3l1.8,1.8l-1.8,1.8c-0.1,0.1-0.1,0.3,0,0.3l0.7,0.7c0.1,0.1,0.3,0.1,0.3,0l1.8-1.8l1.8,1.8 c0.1,0.1,0.3,0.1,0.3,0l0.7-0.7c0.1-0.1,0.1-0.3,0-0.3L-288.9,399.4z'/> </g> </svg>",
@@ -607,5 +621,6 @@ export default {
MAX_PASSWORD_LENGTH: 50,
TIME_SINCE_UPDATE_INTERVAL: 30000,
MIN_HASHTAG_LINK_LENGTH: 3,
- EMOJI_PATH: '/static/emoji'
+ EMOJI_PATH: '/static/emoji',
+ DEFAULT_WEBHOOK_LOGO: logoWebhook
};
diff --git a/webapp/utils/delayed_action.jsx b/webapp/utils/delayed_action.jsx
index 4f6239ad0..c3b164733 100644
--- a/webapp/utils/delayed_action.jsx
+++ b/webapp/utils/delayed_action.jsx
@@ -24,4 +24,8 @@ export default class DelayedAction {
this.timer = window.setTimeout(this.fire, timeout);
}
+
+ cancel() {
+ window.clearTimeout(this.timer);
+ }
}
diff --git a/webapp/utils/emoji.json b/webapp/utils/emoji.json
index 1ccb129f2..c01f5b679 100644
--- a/webapp/utils/emoji.json
+++ b/webapp/utils/emoji.json
@@ -8124,6 +8124,64 @@
]
}
, {
+ "emoji": "πŸ‡¨πŸ‡¦"
+ , "description": "regional indicator symbol letter c + regional indicator symbol letter a"
+ , "aliases": [
+ "ca",
+ "eh"
+ ]
+ , "tags": [
+ "canada"
+ ]
+ }
+, {
+ "emoji": "πŸ‡΅πŸ‡°"
+ , "description": "regional indicator symbol letter p + regional indicator symbol letter k"
+ , "aliases": [
+ "pk"
+ ]
+ , "tags": [
+ "pakistan"
+ ]
+ }
+, {
+ "emoji": "πŸ‡ΏπŸ‡¦"
+ , "description": "regional indicator symbol letter z + regional indicator symbol letter a"
+ , "aliases": [
+ "za"
+ ]
+ , "tags": [
+ "south_africa"
+ ]
+ }
+, {
+ "emoji": "πŸ™‚"
+ , "description": "slightly smiling face"
+ , "aliases": [
+ "slightly_smiling_face"
+ ]
+ , "tags": [
+ ]
+ }
+, {
+ "emoji": "πŸ™"
+ , "description": "slightly frowning face"
+ , "aliases": [
+ "slightly_frowning_face"
+ ]
+ , "tags": [
+ ]
+ }
+, {
+ "emoji": "πŸ™ƒ"
+ , "description": "upside-down face"
+ , "aliases": [
+ "upside_down_face"
+ ]
+ , "tags": [
+ ]
+ }
+, {
"aliases": [
"basecamp"
]
diff --git a/webapp/utils/emoticons.jsx b/webapp/utils/emoticons.jsx
index d3afe372a..86f7a5b7b 100644
--- a/webapp/utils/emoticons.jsx
+++ b/webapp/utils/emoticons.jsx
@@ -7,7 +7,7 @@ import Constants from './constants.jsx';
import emojis from './emoji.json';
const emoticonPatterns = {
- smile: /(^|\s)(:-?\))(?=$|\s)/g, // :)
+ slightly_smiling_face: /(^|\s)(:-?\))(?=$|\s)/g, // :)
wink: /(^|\s)(;-?\))(?=$|\s)/g, // ;)
open_mouth: /(^|\s)(:o)(?=$|\s)/gi, // :o
scream: /(^|\s)(:-o)(?=$|\s)/gi, // :-o
@@ -16,7 +16,7 @@ const emoticonPatterns = {
stuck_out_tongue_closed_eyes: /(^|\s)(x-d)(?=$|\s)/gi, // x-d
stuck_out_tongue: /(^|\s)(:-?p)(?=$|\s)/gi, // :p
rage: /(^|\s)(:-?[\[@])(?=$|\s)/g, // :@
- frowning: /(^|\s)(:-?\()(?=$|\s)/g, // :(
+ slightly_frowning_face: /(^|\s)(:-?\()(?=$|\s)/g, // :(
cry: /(^|\s)(:['’]-?\(|:&#x27;\(|:&#39;\()(?=$|\s)/g, // :`(
confused: /(^|\s)(:-?\/)(?=$|\s)/g, // :/
confounded: /(^|\s)(:-?s)(?=$|\s)/gi, // :s
diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx
index ac12edb82..9b03ef32a 100644
--- a/webapp/utils/utils.jsx
+++ b/webapp/utils/utils.jsx
@@ -14,7 +14,6 @@ var ActionTypes = Constants.ActionTypes;
import * as Client from './client.jsx';
import * as AsyncClient from './async_client.jsx';
import * as client from './client.jsx';
-import Autolinker from 'autolinker';
import React from 'react';
import {browserHistory} from 'react-router';
@@ -169,7 +168,7 @@ export function notifyMe(title, body, channel) {
notification.onclick = () => {
window.focus();
if (channel) {
- switchChannel(channel);
+ GlobalActions.emitChannelClickEvent(channel);
} else {
browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/town-square');
}
@@ -314,14 +313,8 @@ export function getTimestamp() {
}
// extracts links not styled by Markdown
-export function extractLinks(text) {
- text; // eslint-disable-line no-unused-expressions
- Autolinker; // eslint-disable-line no-unused-expressions
-
- // skip this operation because autolinker is having issues
- return [];
-
- /*const links = [];
+export function extractFirstLink(text) {
+ const pattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/i;
let inText = text;
// strip out code blocks
@@ -330,32 +323,12 @@ export function extractLinks(text) {
// strip out inline markdown images
inText = inText.replace(/!\[[^\]]*\]\([^\)]*\)/g, '');
- function replaceFn(autolinker, match) {
- let link = '';
- const matchText = match.getMatchedText();
-
- if (matchText.trim().indexOf('http') === 0) {
- link = matchText;
- } else {
- link = 'http://' + matchText;
- }
-
- links.push(link);
+ const match = pattern.exec(inText);
+ if (match) {
+ return match[0].trim();
}
- Autolinker.link(
- inText,
- {
- replaceFn,
- urls: {schemeMatches: true, wwwMatches: true, tldMatches: false},
- emails: false,
- twitter: false,
- phone: false,
- hashtag: false
- }
- );
-
- return links;*/
+ return '';
}
export function escapeRegExp(string) {
@@ -651,7 +624,7 @@ export function applyTheme(theme) {
}
if (theme.sidebarHeaderBg) {
- changeCss('.sidebar--left .team__header, .sidebar--menu .team__header, .post-list__timestamp', 'background:' + theme.sidebarHeaderBg, 1);
+ changeCss('.sidebar--left .team__header, .sidebar--menu .team__header, .post-list__timestamp > div', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('.modal .modal-header', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('#navbar .navbar-default', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('@media(max-width: 768px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1);
@@ -659,7 +632,7 @@ export function applyTheme(theme) {
}
if (theme.sidebarHeaderTextColor) {
- changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info, .post-list__timestamp', 'color:' + theme.sidebarHeaderTextColor, 1);
+ changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info, .post-list__timestamp > div', 'color:' + theme.sidebarHeaderTextColor, 1);
changeCss('.sidebar--left .team__header .navbar-right .dropdown__icon, .sidebar--menu .team__header .navbar-right .dropdown__icon', 'fill:' + theme.sidebarHeaderTextColor, 1);
changeCss('.sidebar--left .team__header .user__name, .sidebar--menu .team__header .user__name', 'color:' + changeOpacity(theme.sidebarHeaderTextColor, 0.8), 1);
changeCss('.sidebar--left .team__header:hover .user__name, .sidebar--menu .team__header:hover .user__name', 'color:' + theme.sidebarHeaderTextColor, 1);
@@ -756,6 +729,7 @@ export function applyTheme(theme) {
changeCss('.search-help-popover .search-autocomplete__item.selected', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1);
changeCss('::-webkit-scrollbar-thumb', 'background:' + changeOpacity(theme.centerChannelColor, 0.4), 1);
changeCss('body', 'scrollbar-arrow-color:' + theme.centerChannelColor, 4);
+ changeCss('.modal .about-modal .about-modal__logo svg, .post .post__img svg', 'fill:' + theme.centerChannelColor, 1);
}
if (theme.newMessageSeparator) {
@@ -795,6 +769,10 @@ export function applyTheme(theme) {
updateCodeTheme(theme.codeTheme);
}
+export function resetTheme() {
+ applyTheme(Constants.THEMES.default);
+}
+
export function applyFont(fontName) {
const body = $('body');
@@ -957,24 +935,6 @@ export function isValidUsername(name) {
return error;
}
-export function updateAddressBar(channelName) {
- const teamURL = TeamStore.getCurrentTeamUrl();
- history.replaceState('data', '', teamURL + '/channels/' + channelName);
-}
-
-export function switchChannel(channel) {
- GlobalActions.emitChannelClickEvent(channel);
-
- updateAddressBar(channel.name);
-
- $('.inner-wrap').removeClass('move--right');
- $('.sidebar--left').removeClass('move--right');
-
- client.trackPage();
-
- return false;
-}
-
export function isMobile() {
return screen.width <= 768;
}
@@ -1253,7 +1213,7 @@ export function importSlack(file, success, error) {
}
export function getTeamURLFromAddressBar() {
- return window.location.href.split('/channels')[0];
+ return window.location.origin + '/' + window.location.pathname.split('/')[1];
}
export function getShortenedTeamURL() {
@@ -1273,12 +1233,15 @@ export function windowHeight() {
}
export function openDirectChannelToUser(user, successCb, errorCb) {
+ AsyncClient.savePreference(
+ Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
+ user.id,
+ 'true'
+ );
+
const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id);
let channel = ChannelStore.getByName(channelName);
- const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true');
- AsyncClient.savePreferences([preference]);
-
if (channel) {
if ($.isFunction(successCb)) {
successCb(channel, true);
diff --git a/webapp/webpack.config.js b/webapp/webpack.config.js
index ee5c7e70b..4e2d6b70d 100644
--- a/webapp/webpack.config.js
+++ b/webapp/webpack.config.js
@@ -8,8 +8,12 @@ const htmlExtract = new ExtractTextPlugin('html', 'root.html');
const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env
var DEV = false;
-if (NPM_TARGET === 'run') {
+var FULLMAP = false;
+if (NPM_TARGET === 'run' || NPM_TARGET === 'run-fullmap') {
DEV = true;
+ if (NPM_TARGET === 'run-fullmap') {
+ FULLMAP = true;
+ }
}
var config = {
@@ -52,7 +56,7 @@ var config = {
loaders: ['style', 'css']
},
{
- test: /\.(png|eot|tiff|svg|woff2|woff|ttf|gif|mp3)$/,
+ test: /\.(png|eot|tiff|svg|woff2|woff|ttf|gif|mp3|jpg)$/,
loader: 'file',
query: {
name: 'files/[hash].[ext]'
@@ -73,7 +77,9 @@ var config = {
}),
htmlExtract,
new CopyWebpackPlugin([
- {from: 'images/emoji', to: 'emoji'}
+ {from: 'images/emoji', to: 'emoji'},
+ {from: 'images/logo-email.png', to: 'images'},
+ {from: 'images/circles.png', to: 'images'}
]),
new webpack.LoaderOptionsPlugin({
minimize: !DEV,
@@ -94,7 +100,11 @@ var config = {
// Development mode configuration
if (DEV) {
- config.devtool = 'eval-cheap-module-source-map';
+ if (FULLMAP) {
+ config.devtool = 'source-map';
+ } else {
+ config.devtool = 'eval-cheap-module-source-map';
+ }
}
// Production mode configuration