diff options
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 +} @@ -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 @@ -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:' + /> + {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:' + /> + {config.Version} ({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> + {config.BuildHash} + </p> + <p> + <FormattedMessage + id='about.date' + defaultMessage='Build Date:' + /> + {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 Binary files differnew file mode 100644 index 000000000..57f487c22 --- /dev/null +++ b/webapp/images/emoji/1f1e8-1f1e6.png diff --git a/webapp/images/emoji/1f1f5-1f1f0.png b/webapp/images/emoji/1f1f5-1f1f0.png Binary files differnew file mode 100644 index 000000000..17c4f6db5 --- /dev/null +++ b/webapp/images/emoji/1f1f5-1f1f0.png diff --git a/webapp/images/emoji/1f1ff-1e1e6.png b/webapp/images/emoji/1f1ff-1e1e6.png Binary files differnew file mode 100644 index 000000000..8909fe82a --- /dev/null +++ b/webapp/images/emoji/1f1ff-1e1e6.png diff --git a/webapp/images/emoji/1f641.png b/webapp/images/emoji/1f641.png Binary files differnew file mode 100644 index 000000000..7041b0804 --- /dev/null +++ b/webapp/images/emoji/1f641.png diff --git a/webapp/images/emoji/1f642.png b/webapp/images/emoji/1f642.png Binary files differnew file mode 100644 index 000000000..abd534797 --- /dev/null +++ b/webapp/images/emoji/1f642.png diff --git a/webapp/images/emoji/1f643.png b/webapp/images/emoji/1f643.png Binary files differnew file mode 100644 index 000000000..3cb9f962f --- /dev/null +++ b/webapp/images/emoji/1f643.png 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)(:['β]-?\(|:'\(|:'\()(?=$|\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 |