Merge pull request #26 from mattermost/signup-token

Signup token
This commit is contained in:
Chen-I Lim 2021-01-14 09:11:06 -08:00 committed by GitHub
commit 4d199ba8af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 708 additions and 80 deletions

View file

@ -15,6 +15,7 @@ import (
"github.com/gorilla/mux"
"github.com/mattermost/mattermost-octo-tasks/server/app"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/mattermost/mattermost-octo-tasks/server/utils"
)
// ----------------------------------------------------------------------------------------------------
@ -53,6 +54,9 @@ func (a *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/sharing/{rootID}", a.sessionRequired(a.handlePostSharing)).Methods("POST")
r.HandleFunc("/api/v1/sharing/{rootID}", a.handleGetSharing).Methods("GET")
r.HandleFunc("/api/v1/workspace", a.sessionRequired(a.handleGetWorkspace)).Methods("GET")
r.HandleFunc("/api/v1/workspace/regenerate_signup_token", a.sessionRequired(a.handlePostWorkspaceRegenerateSignupToken)).Methods("POST")
}
func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
@ -427,7 +431,6 @@ func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
@ -435,7 +438,6 @@ func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
@ -447,7 +449,6 @@ func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse(w, http.StatusInternalServerError, nil)
return
}
@ -456,7 +457,6 @@ func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) {
if r := recover(); r != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
}()
@ -466,7 +466,6 @@ func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) {
err = json.Unmarshal(requestBody, &sharing)
if err != nil {
errorResponse(w, http.StatusInternalServerError, nil)
return
}
@ -483,7 +482,6 @@ func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf(`ERROR: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
@ -491,6 +489,46 @@ func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) {
jsonStringResponse(w, http.StatusOK, "{}")
}
// Workspace
func (a *API) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
workspace, err := a.app().GetRootWorkspace()
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
workspaceData, err := json.Marshal(workspace)
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
jsonStringResponse(w, http.StatusOK, string(workspaceData))
}
func (a *API) handlePostWorkspaceRegenerateSignupToken(w http.ResponseWriter, r *http.Request) {
workspace, err := a.app().GetRootWorkspace()
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
workspace.SignupToken = utils.CreateGUID()
err = a.app().UpsertWorkspaceSignupToken(*workspace)
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
jsonStringResponse(w, http.StatusOK, "{}")
}
// File upload
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {

View file

@ -26,6 +26,7 @@ type RegisterData struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Token string `json:"token"`
}
func (rd *RegisterData) IsValid() error {
@ -77,14 +78,12 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
}
errorResponse(w, http.StatusInternalServerError, map[string]string{"error": "Unknown login type"})
return
}
func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse(w, http.StatusInternalServerError, nil)
return
}
@ -95,6 +94,33 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
// Validate token
if len(registerData.Token) > 0 {
workspace, err := a.app().GetRootWorkspace()
if err != nil {
log.Println("ERROR: Unable to get active user count", err)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
if registerData.Token != workspace.SignupToken {
errorResponse(w, http.StatusUnauthorized, nil)
return
}
} else {
// No signup token, check if no active users
userCount, err := a.app().GetActiveUserCount()
if err != nil {
log.Println("ERROR: Unable to get active user count", err)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
if userCount > 0 {
errorResponse(w, http.StatusUnauthorized, nil)
return
}
}
if err = registerData.IsValid(); err != nil {
errorResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
@ -105,8 +131,8 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
errorResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonBytesResponse(w, http.StatusOK, nil)
return
}
func (a *API) sessionRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {

View file

@ -27,6 +27,11 @@ func (a *App) GetSession(token string) (*model.Session, error) {
return session, nil
}
// GetActiveUserCount returns the number of active users
func (a *App) GetActiveUserCount() (int, error) {
return a.store.GetActiveUserCount()
}
// GetUser Get an existing active user by id
func (a *App) GetUser(ID string) (*model.User, error) {
if len(ID) < 1 {

53
server/app/workspaces.go Normal file
View file

@ -0,0 +1,53 @@
package app
import (
"database/sql"
"log"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/mattermost/mattermost-octo-tasks/server/utils"
)
func (a *App) GetRootWorkspace() (*model.Workspace, error) {
workspaceID := "0"
workspace, _ := a.store.GetWorkspace(workspaceID)
if workspace == nil {
workspace = &model.Workspace{
ID: workspaceID,
SignupToken: utils.CreateGUID(),
}
err := a.store.UpsertWorkspaceSignupToken(*workspace)
if err != nil {
log.Fatal("Unable to initialize workspace", err)
return nil, err
}
workspace, err = a.store.GetWorkspace(workspaceID)
if err != nil {
log.Fatal("Unable to get initialized workspace", err)
return nil, err
}
log.Println("initialized workspace")
}
return workspace, nil
}
func (a *App) getWorkspace(ID string) (*model.Workspace, error) {
workspace, err := a.store.GetWorkspace(ID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return workspace, nil
}
func (a *App) UpsertWorkspaceSettings(workspace model.Workspace) error {
return a.store.UpsertWorkspaceSettings(workspace)
}
func (a *App) UpsertWorkspaceSignupToken(workspace model.Workspace) error {
return a.store.UpsertWorkspaceSignupToken(workspace)
}

View file

@ -0,0 +1,9 @@
package model
type Workspace struct {
ID string `json:"id"`
SignupToken string `json:"signupToken"`
Settings map[string]interface{} `json:"settings"`
ModifiedBy string `json:"modifiedBy"`
UpdateAt int64 `json:"updateAt"`
}

View file

@ -47,7 +47,6 @@ func New(cfg *config.Configuration, singleUser bool) (*Server, error) {
store, err := sqlstore.New(cfg.DBType, cfg.DBConfigString)
if err != nil {
log.Fatal("Unable to start the database", err)
return nil, err
}
@ -68,6 +67,9 @@ func New(cfg *config.Configuration, singleUser bool) (*Server, error) {
appBuilder := func() *app.App { return app.New(cfg, store, wsServer, filesBackend, webhookClient) }
api := api.NewAPI(appBuilder, singleUser)
// Init workspace
appBuilder().GetRootWorkspace()
webServer := web.NewServer(cfg.WebPath, cfg.Port, cfg.UseSSL)
webServer.AddRoutes(wsServer)
webServer.AddRoutes(api)

View file

@ -103,6 +103,21 @@ func (mr *MockStoreMockRecorder) DeleteSession(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSession", reflect.TypeOf((*MockStore)(nil).DeleteSession), arg0)
}
// GetActiveUserCount mocks base method
func (m *MockStore) GetActiveUserCount() (int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveUserCount")
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetActiveUserCount indicates an expected call of GetActiveUserCount
func (mr *MockStoreMockRecorder) GetActiveUserCount() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUserCount", reflect.TypeOf((*MockStore)(nil).GetActiveUserCount))
}
// GetAllBlocks mocks base method
func (m *MockStore) GetAllBlocks() ([]model.Block, error) {
m.ctrl.T.Helper()
@ -313,6 +328,21 @@ func (mr *MockStoreMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockStore)(nil).GetUserByUsername), arg0)
}
// GetWorkspace mocks base method
func (m *MockStore) GetWorkspace(arg0 string) (*model.Workspace, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspace", arg0)
ret0, _ := ret[0].(*model.Workspace)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspace indicates an expected call of GetWorkspace
func (mr *MockStoreMockRecorder) GetWorkspace(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspace", reflect.TypeOf((*MockStore)(nil).GetWorkspace), arg0)
}
// InsertBlock mocks base method
func (m *MockStore) InsertBlock(arg0 model.Block) error {
m.ctrl.T.Helper()
@ -410,3 +440,31 @@ func (mr *MockStoreMockRecorder) UpsertSharing(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertSharing", reflect.TypeOf((*MockStore)(nil).UpsertSharing), arg0)
}
// UpsertWorkspaceSettings mocks base method
func (m *MockStore) UpsertWorkspaceSettings(arg0 model.Workspace) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertWorkspaceSettings", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertWorkspaceSettings indicates an expected call of UpsertWorkspaceSettings
func (mr *MockStoreMockRecorder) UpsertWorkspaceSettings(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceSettings", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceSettings), arg0)
}
// UpsertWorkspaceSignupToken mocks base method
func (m *MockStore) UpsertWorkspaceSignupToken(arg0 model.Workspace) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertWorkspaceSignupToken", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertWorkspaceSignupToken indicates an expected call of UpsertWorkspaceSignupToken
func (mr *MockStoreMockRecorder) UpsertWorkspaceSignupToken(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceSignupToken", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceSignupToken), arg0)
}

View file

@ -12,6 +12,8 @@
// postgres_files/000005_blocks_modifiedby.up.sql
// postgres_files/000006_sharing_table.down.sql
// postgres_files/000006_sharing_table.up.sql
// postgres_files/000007_workspaces_table.down.sql
// postgres_files/000007_workspaces_table.up.sql
package postgres
import (
@ -292,7 +294,7 @@ func _000006_sharing_tableDownSql() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "000006_sharing_table.down.sql", size: 20, mode: os.FileMode(420), modTime: time.Unix(1610482431, 0)}
info := bindataFileInfo{name: "000006_sharing_table.down.sql", size: 20, mode: os.FileMode(420), modTime: time.Unix(1610576067, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@ -312,7 +314,47 @@ func _000006_sharing_tableUpSql() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "000006_sharing_table.up.sql", size: 159, mode: os.FileMode(420), modTime: time.Unix(1610483324, 0)}
info := bindataFileInfo{name: "000006_sharing_table.up.sql", size: 159, mode: os.FileMode(420), modTime: time.Unix(1610576067, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var __000007_workspaces_tableDownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x28\xcf\x2f\xca\x2e\x2e\x48\x4c\x4e\x2d\xb6\xe6\x02\x04\x00\x00\xff\xff\xc4\x05\x92\x8e\x17\x00\x00\x00")
func _000007_workspaces_tableDownSqlBytes() ([]byte, error) {
return bindataRead(
__000007_workspaces_tableDownSql,
"000007_workspaces_table.down.sql",
)
}
func _000007_workspaces_tableDownSql() (*asset, error) {
bytes, err := _000007_workspaces_tableDownSqlBytes()
if err != nil {
return nil, err
}
info := bindataFileInfo{name: "000007_workspaces_table.down.sql", size: 23, mode: os.FileMode(420), modTime: time.Unix(1610576169, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var __000007_workspaces_tableUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x5c\xcc\xc1\x6a\x83\x30\x00\x06\xe0\x73\xf2\x14\xff\xd1\x40\x0e\x8e\xc1\x2e\x3b\x45\xc9\xb6\x6c\x2e\x96\x98\x96\x7a\x12\xdb\xa4\x12\xa4\x2a\x4d\xa4\xf4\xed\x0b\x3d\xf4\xd0\xf3\x07\x5f\x69\xa4\xb0\x12\x56\x14\x95\x84\xfa\x82\xae\x2d\xe4\x5e\x35\xb6\xc1\x75\xbe\x8c\x71\xe9\x8f\x3e\x22\xa3\x24\x38\xec\x84\x29\x7f\x84\xc9\xde\x3f\x18\xa7\x24\x86\x61\x5a\x97\x2e\xcd\xa3\x9f\x9e\xf4\x96\xe7\xec\x71\xe8\x6d\x55\x71\x0a\x00\xd1\xa7\x14\xa6\x21\xe2\xb7\xa9\x35\xa7\xe4\x3c\xbb\x70\x0a\xde\x75\x87\xdb\xcb\xb8\x2e\xae\x4f\xbe\xeb\x13\x0a\xf5\xad\xb4\xe5\x94\x6c\x8c\xfa\x17\xa6\xc5\x9f\x6c\x91\x05\xc7\x28\xfb\xa4\xf7\x00\x00\x00\xff\xff\x3b\x70\x91\x2c\xb3\x00\x00\x00")
func _000007_workspaces_tableUpSqlBytes() ([]byte, error) {
return bindataRead(
__000007_workspaces_tableUpSql,
"000007_workspaces_table.up.sql",
)
}
func _000007_workspaces_tableUpSql() (*asset, error) {
bytes, err := _000007_workspaces_tableUpSqlBytes()
if err != nil {
return nil, err
}
info := bindataFileInfo{name: "000007_workspaces_table.up.sql", size: 179, mode: os.FileMode(420), modTime: time.Unix(1610577228, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@ -381,6 +423,8 @@ var _bindata = map[string]func() (*asset, error){
"000005_blocks_modifiedby.up.sql": _000005_blocks_modifiedbyUpSql,
"000006_sharing_table.down.sql": _000006_sharing_tableDownSql,
"000006_sharing_table.up.sql": _000006_sharing_tableUpSql,
"000007_workspaces_table.down.sql": _000007_workspaces_tableDownSql,
"000007_workspaces_table.up.sql": _000007_workspaces_tableUpSql,
}
// AssetDir returns the file names below a certain
@ -435,6 +479,8 @@ var _bintree = &bintree{nil, map[string]*bintree{
"000005_blocks_modifiedby.up.sql": &bintree{_000005_blocks_modifiedbyUpSql, map[string]*bintree{}},
"000006_sharing_table.down.sql": &bintree{_000006_sharing_tableDownSql, map[string]*bintree{}},
"000006_sharing_table.up.sql": &bintree{_000006_sharing_tableUpSql, map[string]*bintree{}},
"000007_workspaces_table.down.sql": &bintree{_000007_workspaces_tableDownSql, map[string]*bintree{}},
"000007_workspaces_table.up.sql": &bintree{_000007_workspaces_tableUpSql, map[string]*bintree{}},
}}
// RestoreAsset restores an asset under the given directory

View file

@ -0,0 +1 @@
DROP TABLE workspaces;

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS workspaces (
id VARCHAR(36),
signup_token VARCHAR(100) NOT NULL,
settings JSON,
modified_by VARCHAR(36),
update_at BIGINT,
PRIMARY KEY (id)
);

View file

@ -12,6 +12,8 @@
// sqlite_files/000005_blocks_modifiedby.up.sql
// sqlite_files/000006_sharing_table.down.sql
// sqlite_files/000006_sharing_table.up.sql
// sqlite_files/000007_workspaces_table.down.sql
// sqlite_files/000007_workspaces_table.up.sql
package sqlite
import (
@ -292,7 +294,7 @@ func _000006_sharing_tableDownSql() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "000006_sharing_table.down.sql", size: 20, mode: os.FileMode(420), modTime: time.Unix(1610482438, 0)}
info := bindataFileInfo{name: "000006_sharing_table.down.sql", size: 20, mode: os.FileMode(420), modTime: time.Unix(1610576067, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@ -312,7 +314,47 @@ func _000006_sharing_tableUpSql() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "000006_sharing_table.up.sql", size: 159, mode: os.FileMode(420), modTime: time.Unix(1610483328, 0)}
info := bindataFileInfo{name: "000006_sharing_table.up.sql", size: 159, mode: os.FileMode(420), modTime: time.Unix(1610576067, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var __000007_workspaces_tableDownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x28\xcf\x2f\xca\x2e\x2e\x48\x4c\x4e\x2d\xb6\xe6\x02\x04\x00\x00\xff\xff\xc4\x05\x92\x8e\x17\x00\x00\x00")
func _000007_workspaces_tableDownSqlBytes() ([]byte, error) {
return bindataRead(
__000007_workspaces_tableDownSql,
"000007_workspaces_table.down.sql",
)
}
func _000007_workspaces_tableDownSql() (*asset, error) {
bytes, err := _000007_workspaces_tableDownSqlBytes()
if err != nil {
return nil, err
}
info := bindataFileInfo{name: "000007_workspaces_table.down.sql", size: 23, mode: os.FileMode(420), modTime: time.Unix(1610576588, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var __000007_workspaces_tableUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x5c\xcc\x41\xcb\x82\x30\x00\x87\xf1\xb3\xfb\x14\xff\xa3\x03\x0f\xbe\xbc\xd0\xa5\xd3\x94\x55\x23\xb3\x98\x2b\xf4\x24\xd6\x96\x0c\x49\x47\x9b\x44\xdf\x3e\xea\xd0\xa1\xf3\xf3\xf0\xcb\x25\x67\x8a\x43\xb1\xac\xe0\x10\x2b\x94\x7b\x05\x5e\x8b\x4a\x55\x78\x4c\xf7\xc1\xbb\xee\x62\x3c\x62\x12\x59\x8d\x13\x93\xf9\x86\xc9\xf8\x7f\x41\x13\x12\x79\xdb\x8f\xb3\x6b\xc3\x34\x98\xf1\x9b\xfe\xd2\x94\x7e\x8c\xf2\x58\x14\x09\x01\x00\x6f\x42\xb0\x63\xef\xa1\x78\xad\x12\x12\xdd\x26\x6d\xaf\xd6\xe8\xf6\xfc\xfc\x11\x67\xa7\xbb\x60\xda\x2e\x20\x13\x6b\x51\xbe\xe7\x83\x14\x3b\x26\x1b\x6c\x79\x83\xd8\x6a\x4a\xe8\x92\xbc\x02\x00\x00\xff\xff\xa0\xd9\x01\x00\xb3\x00\x00\x00")
func _000007_workspaces_tableUpSqlBytes() ([]byte, error) {
return bindataRead(
__000007_workspaces_tableUpSql,
"000007_workspaces_table.up.sql",
)
}
func _000007_workspaces_tableUpSql() (*asset, error) {
bytes, err := _000007_workspaces_tableUpSqlBytes()
if err != nil {
return nil, err
}
info := bindataFileInfo{name: "000007_workspaces_table.up.sql", size: 179, mode: os.FileMode(420), modTime: time.Unix(1610577231, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@ -381,6 +423,8 @@ var _bindata = map[string]func() (*asset, error){
"000005_blocks_modifiedby.up.sql": _000005_blocks_modifiedbyUpSql,
"000006_sharing_table.down.sql": _000006_sharing_tableDownSql,
"000006_sharing_table.up.sql": _000006_sharing_tableUpSql,
"000007_workspaces_table.down.sql": _000007_workspaces_tableDownSql,
"000007_workspaces_table.up.sql": _000007_workspaces_tableUpSql,
}
// AssetDir returns the file names below a certain
@ -435,6 +479,8 @@ var _bintree = &bintree{nil, map[string]*bintree{
"000005_blocks_modifiedby.up.sql": &bintree{_000005_blocks_modifiedbyUpSql, map[string]*bintree{}},
"000006_sharing_table.down.sql": &bintree{_000006_sharing_tableDownSql, map[string]*bintree{}},
"000006_sharing_table.up.sql": &bintree{_000006_sharing_tableUpSql, map[string]*bintree{}},
"000007_workspaces_table.down.sql": &bintree{_000007_workspaces_tableDownSql, map[string]*bintree{}},
"000007_workspaces_table.up.sql": &bintree{_000007_workspaces_tableUpSql, map[string]*bintree{}},
}}
// RestoreAsset restores an asset under the given directory

View file

@ -0,0 +1 @@
DROP TABLE workspaces;

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS workspaces (
id VARCHAR(36),
signup_token VARCHAR(100) NOT NULL,
settings TEXT,
modified_by VARCHAR(36),
update_at BIGINT,
PRIMARY KEY (id)
);

View file

@ -9,6 +9,22 @@ import (
sq "github.com/Masterminds/squirrel"
)
func (s *SQLStore) GetActiveUserCount() (int, error) {
query := s.getQueryBuilder().
Select("count(*)").
From("users").
Where(sq.Eq{"delete_at": 0})
row := query.QueryRow()
var count int
err := row.Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (s *SQLStore) getUserByCondition(condition sq.Eq) (*model.User, error) {
query := s.getQueryBuilder().
Select("id", "username", "email", "password", "mfa_secret", "auth_service", "auth_data", "props", "create_at", "update_at", "delete_at").

View file

@ -0,0 +1,98 @@
package sqlstore
import (
"encoding/json"
"log"
"time"
"github.com/mattermost/mattermost-octo-tasks/server/model"
sq "github.com/Masterminds/squirrel"
)
func (s *SQLStore) UpsertWorkspaceSignupToken(workspace model.Workspace) error {
now := time.Now().Unix()
query := s.getQueryBuilder().
Insert("workspaces").
Columns(
"id",
"signup_token",
"modified_by",
"update_at",
).
Values(
workspace.ID,
workspace.SignupToken,
workspace.ModifiedBy,
now,
).
Suffix("ON CONFLICT (id) DO UPDATE SET signup_token = EXCLUDED.signup_token, modified_by = EXCLUDED.modified_by, update_at = EXCLUDED.update_at")
_, err := query.Exec()
return err
}
func (s *SQLStore) UpsertWorkspaceSettings(workspace model.Workspace) error {
now := time.Now().Unix()
settingsJSON, err := json.Marshal(workspace.Settings)
if err != nil {
return err
}
query := s.getQueryBuilder().
Insert("workspaces").
Columns(
"id",
"settings",
"modified_by",
"update_at",
).
Values(
workspace.ID,
settingsJSON,
workspace.ModifiedBy,
now,
).
Suffix("ON CONFLICT (id) DO UPDATE SET settings = EXCLUDED.settings, modified_by = EXCLUDED.modified_by, update_at = EXCLUDED.update_at")
_, err = query.Exec()
return err
}
func (s *SQLStore) GetWorkspace(ID string) (*model.Workspace, error) {
var settingsJSON string
query := s.getQueryBuilder().
Select(
"id",
"signup_token",
"COALESCE(\"settings\", '{}')",
"modified_by",
"update_at",
).
From("workspaces").
Where(sq.Eq{"id": ID})
row := query.QueryRow()
workspace := model.Workspace{}
err := row.Scan(
&workspace.ID,
&workspace.SignupToken,
&settingsJSON,
&workspace.ModifiedBy,
&workspace.UpdateAt,
)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(settingsJSON), &workspace.Settings)
if err != nil {
log.Printf(`ERROR GetWorkspace settings json.Unmarshal: %v`, err)
return nil, err
}
return &workspace, nil
}

View file

@ -21,6 +21,7 @@ type Store interface {
GetSystemSettings() (map[string]string, error)
SetSystemSetting(key string, value string) error
GetActiveUserCount() (int, error)
GetUserById(userID string) (*model.User, error)
GetUserByEmail(email string) (*model.User, error)
GetUserByUsername(username string) (*model.User, error)
@ -36,4 +37,8 @@ type Store interface {
UpsertSharing(sharing model.Sharing) error
GetSharing(rootID string) (*model.Sharing, error)
UpsertWorkspaceSignupToken(workspace model.Workspace) error
UpsertWorkspaceSettings(workspace model.Workspace) error
GetWorkspace(ID string) (*model.Workspace, error)
}

View file

@ -60,6 +60,11 @@
"PropertyType.URL": "URL",
"PropertyType.UpdatedBy": "Updated By",
"PropertyType.UpdatedTime": "Updated Time",
"RegistrationLink.confirmRegenerateToken": "This will invalidate previously shared links. Continue?",
"RegistrationLink.copiedLink": "Copied!",
"RegistrationLink.copyLink": "Copy link",
"RegistrationLink.regenerateToken": "Regenerate token",
"RegistrationLink.tokenRegenerated": "Registration link regenerated",
"ShareBoard.confirmRegenerateToken": "This will invalidate previously shared links. Continue?",
"ShareBoard.copiedLink": "Copied!",
"ShareBoard.copyLink": "Copy link",
@ -79,6 +84,7 @@
"Sidebar.english": "English",
"Sidebar.export-archive": "Export archive",
"Sidebar.import-archive": "Import archive",
"Sidebar.invite-users": "Invite Users",
"Sidebar.light-theme": "Light theme",
"Sidebar.no-views-in-board": "No pages inside",
"Sidebar.select-a-template": "Select a template",

View file

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
interface IWorkspace {
readonly id: string,
readonly signupToken: string,
readonly settings: Readonly<Record<string, any>>
readonly modifiedBy?: string,
readonly updateAt?: number,
}
export {IWorkspace}

View file

@ -18,6 +18,14 @@
min-width: 0;
}
@media not screen and (max-width: 430px) {
&.top {
top: auto;
bottom: 25px;
left: 25px;
}
}
.hideOnWidescreen {
/* Hide controls (e.g. close button) on larger screens */
@media not screen and (max-width: 430px) {

View file

@ -10,6 +10,7 @@ import './modal.scss'
type Props = {
onClose: () => void
intl: IntlShape
position?: 'top'|'bottom'
}
class Modal extends React.PureComponent<Props> {
@ -37,9 +38,11 @@ class Modal extends React.PureComponent<Props> {
}
render(): JSX.Element {
const {position} = this.props
return (
<div
className='Modal'
className={'Modal ' + (position || 'bottom')}
ref={this.node}
>
<div className='toolbar hideOnWidescreen'>

View file

@ -0,0 +1,28 @@
.RegistrationLinkComponent {
display: flex;
flex-direction: column;
padding: 5px;
color: rgb(var(--main-fg));
> .row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10px;
}
> .row:last-child {
margin-bottom: 0;
}
.spacer {
flex-grow: 1;
}
input.shareUrl {
flex-grow: 1;
border: solid 1px #cccccc;
margin-right: 5px;
padding: 5px;
}
}

View file

@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IWorkspace} from '../blocks/workspace'
import {sendFlashMessage} from '../components/flashMessages'
import client from '../octoClient'
import {Utils} from '../utils'
import Button from '../widgets/buttons/button'
import Modal from './modal'
import './registrationLinkComponent.scss'
type Props = {
onClose: () => void
intl: IntlShape
}
type State = {
workspace?: IWorkspace
wasCopied?: boolean
}
class RegistrationLinkComponent extends React.PureComponent<Props, State> {
state: State = {}
componentDidMount() {
this.loadData()
}
private async loadData() {
const workspace = await client.getWorkspace()
this.setState({workspace})
}
render(): JSX.Element {
const {intl} = this.props
const {workspace} = this.state
const registrationUrl = window.location.origin + '/register?t=' + workspace?.signupToken
return (
<Modal
position='top'
onClose={this.props.onClose}
>
<div className='RegistrationLinkComponent'>
{workspace && <>
<div className='row'>
<input
key={registrationUrl}
className='shareUrl'
readOnly={true}
value={registrationUrl}
/>
<Button
filled={true}
onClick={() => {
Utils.copyTextToClipboard(registrationUrl)
this.setState({wasCopied: true})
}}
>
{this.state.wasCopied ? intl.formatMessage({id: 'RegistrationLink.copiedLink', defaultMessage: 'Copied!'}) : intl.formatMessage({id: 'RegistrationLink.copyLink', defaultMessage: 'Copy link'})}
</Button>
</div>
<div className='row'>
<Button onClick={this.onRegenerateToken}>
{intl.formatMessage({id: 'RegistrationLink.regenerateToken', defaultMessage: 'Regenerate token'})}
</Button>
</div>
</>}
</div>
</Modal>
)
}
private onRegenerateToken = async () => {
const {intl} = this.props
// eslint-disable-next-line no-alert
const accept = window.confirm(intl.formatMessage({id: 'RegistrationLink.confirmRegenerateToken', defaultMessage: 'This will invalidate previously shared links. Continue?'}))
if (accept) {
await client.regenerateWorkspaceSignupToken()
await this.loadData()
const description = intl.formatMessage({id: 'RegistrationLink.tokenRegenerated', defaultMessage: 'Registration link regenerated'})
sendFlashMessage({content: description, severity: 'low'})
}
}
}
export default injectIntl(RegistrationLinkComponent)

View file

@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
padding: 5px;
color: rgb(var(--main-fg));
> .row {
display: flex;

View file

@ -7,7 +7,7 @@ import {Archiver} from '../archiver'
import {Board, MutableBoard} from '../blocks/board'
import {BoardView, MutableBoardView} from '../blocks/boardView'
import mutator from '../mutator'
import {defaultTheme, darkTheme, lightTheme, setTheme} from '../theme'
import {darkTheme, defaultTheme, lightTheme, setTheme} from '../theme'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
@ -22,6 +22,9 @@ import OptionsIcon from '../widgets/icons/options'
import ShowSidebarIcon from '../widgets/icons/showSidebar'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import ModalWrapper from './modalWrapper'
import RegistrationLinkComponent from './registrationLinkComponent'
import './sidebar.scss'
type Props = {
@ -36,6 +39,7 @@ type Props = {
type State = {
isHidden: boolean
collapsedBoards: {[key: string]: boolean}
showRegistrationLinkDialog?: boolean
}
class Sidebar extends React.Component<Props, State> {
@ -263,63 +267,79 @@ class Sidebar extends React.Component<Props, State> {
</Menu>
</MenuWrapper>
<MenuWrapper>
<Button>
<FormattedMessage
id='Sidebar.settings'
defaultMessage='Settings'
<ModalWrapper>
<MenuWrapper>
<Button>
<FormattedMessage
id='Sidebar.settings'
defaultMessage='Settings'
/>
</Button>
<Menu position='top'>
<Menu.Text
id='invite'
name={intl.formatMessage({id: 'Sidebar.invite-users', defaultMessage: 'Invite Users'})}
onClick={async () => {
this.setState({showRegistrationLinkDialog: true})
}}
/>
<Menu.Text
id='import'
name={intl.formatMessage({id: 'Sidebar.import-archive', defaultMessage: 'Import archive'})}
onClick={async () => Archiver.importFullArchive()}
/>
<Menu.Text
id='export'
name={intl.formatMessage({id: 'Sidebar.export-archive', defaultMessage: 'Export archive'})}
onClick={async () => Archiver.exportFullArchive()}
/>
<Menu.SubMenu
id='lang'
name={intl.formatMessage({id: 'Sidebar.set-language', defaultMessage: 'Set language'})}
position='top'
>
<Menu.Text
id='english-lang'
name={intl.formatMessage({id: 'Sidebar.english', defaultMessage: 'English'})}
onClick={async () => this.props.setLanguage('en')}
/>
<Menu.Text
id='spanish-lang'
name={intl.formatMessage({id: 'Sidebar.spanish', defaultMessage: 'Spanish'})}
onClick={async () => this.props.setLanguage('es')}
/>
</Menu.SubMenu>
<Menu.SubMenu
id='theme'
name={intl.formatMessage({id: 'Sidebar.set-theme', defaultMessage: 'Set theme'})}
position='top'
>
<Menu.Text
id='default-theme'
name={intl.formatMessage({id: 'Sidebar.default-theme', defaultMessage: 'Default theme'})}
onClick={async () => setTheme(defaultTheme)}
/>
<Menu.Text
id='dark-theme'
name={intl.formatMessage({id: 'Sidebar.dark-theme', defaultMessage: 'Dark theme'})}
onClick={async () => setTheme(darkTheme)}
/>
<Menu.Text
id='light-theme'
name={intl.formatMessage({id: 'Sidebar.light-theme', defaultMessage: 'Light theme'})}
onClick={async () => setTheme(lightTheme)}
/>
</Menu.SubMenu>
</Menu>
</MenuWrapper>
{this.state.showRegistrationLinkDialog &&
<RegistrationLinkComponent
onClose={() => {
this.setState({showRegistrationLinkDialog: false})
}}
/>
</Button>
<Menu position='top'>
<Menu.Text
id='import'
name={intl.formatMessage({id: 'Sidebar.import-archive', defaultMessage: 'Import archive'})}
onClick={async () => Archiver.importFullArchive()}
/>
<Menu.Text
id='export'
name={intl.formatMessage({id: 'Sidebar.export-archive', defaultMessage: 'Export archive'})}
onClick={async () => Archiver.exportFullArchive()}
/>
<Menu.SubMenu
id='lang'
name={intl.formatMessage({id: 'Sidebar.set-language', defaultMessage: 'Set language'})}
position='top'
>
<Menu.Text
id='english-lang'
name={intl.formatMessage({id: 'Sidebar.english', defaultMessage: 'English'})}
onClick={async () => this.props.setLanguage('en')}
/>
<Menu.Text
id='spanish-lang'
name={intl.formatMessage({id: 'Sidebar.spanish', defaultMessage: 'Spanish'})}
onClick={async () => this.props.setLanguage('es')}
/>
</Menu.SubMenu>
<Menu.SubMenu
id='theme'
name={intl.formatMessage({id: 'Sidebar.set-theme', defaultMessage: 'Set theme'})}
position='top'
>
<Menu.Text
id='default-theme'
name={intl.formatMessage({id: 'Sidebar.default-theme', defaultMessage: 'Default theme'})}
onClick={async () => setTheme(defaultTheme)}
/>
<Menu.Text
id='dark-theme'
name={intl.formatMessage({id: 'Sidebar.dark-theme', defaultMessage: 'Dark theme'})}
onClick={async () => setTheme(darkTheme)}
/>
<Menu.Text
id='light-theme'
name={intl.formatMessage({id: 'Sidebar.light-theme', defaultMessage: 'Light theme'})}
onClick={async () => setTheme(lightTheme)}
/>
</Menu.SubMenu>
</Menu>
</MenuWrapper>
}
</ModalWrapper>
</div>
)
}

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {IBlock, IMutableBlock} from './blocks/block'
import {ISharing} from './blocks/sharing'
import {IWorkspace} from './blocks/workspace'
import {IUser} from './user'
import {Utils} from './utils'
@ -43,18 +44,18 @@ class OctoClient {
return false
}
async register(email: string, username: string, password: string): Promise<boolean> {
async register(email: string, username: string, password: string, token?: string): Promise<200 | 401 | 500> {
const path = '/api/v1/register'
const body = JSON.stringify({email, username, password})
const body = JSON.stringify({email, username, password, token})
const response = await fetch(this.serverUrl + path, {
method: 'POST',
headers: this.headers(),
body,
})
if (response.status === 200) {
return true
if (response.status === 200 || response.status === 401) {
return response.status
}
return false
return 500
}
private headers() {
@ -242,6 +243,28 @@ class OctoClient {
}
return false
}
// Workspace
async getWorkspace(): Promise<IWorkspace> {
const path = '/api/v1/workspace'
const response = await fetch(this.serverUrl + path, {headers: this.headers()})
const workspace = (await response.json()) as IWorkspace || null
return workspace
}
async regenerateWorkspaceSignupToken(): Promise<boolean> {
const path = '/api/v1/workspace/regenerate_signup_token'
const response = await fetch(this.serverUrl + path, {
method: 'POST',
headers: this.headers(),
})
if (response.status === 200) {
return true
}
return false
}
}
function getReadToken(): string {

View file

@ -24,4 +24,7 @@
.Button {
margin-top: 10px;
}
.error {
color: #900000;
}
}

View file

@ -18,22 +18,30 @@ type State = {
email: string
username: string
password: string
errorMessage?: string
}
class RegisterPage extends React.PureComponent<Props, State> {
state = {
state: State = {
email: '',
username: '',
password: '',
}
private handleRegister = async (): Promise<void> => {
const registered = await client.register(this.state.email, this.state.username, this.state.password)
if (registered) {
const queryString = new URLSearchParams(window.location.search)
const signupToken = queryString.get('t') || ''
const registered = await client.register(this.state.email, this.state.username, this.state.password, signupToken)
if (registered === 200) {
const logged = await client.login(this.state.username, this.state.password)
if (logged) {
this.props.history.push('/')
}
} else if (registered === 401) {
this.setState({errorMessage: 'Invalid registration link, please contact your administrator'})
} else {
this.setState({errorMessage: 'Server error'})
}
}
@ -67,6 +75,11 @@ class RegisterPage extends React.PureComponent<Props, State> {
</div>
<Button onClick={this.handleRegister}>{'Register'}</Button>
<Link to='/login'>{'or login if you already have an account'}</Link>
{this.state.errorMessage &&
<div className='error'>
{this.state.errorMessage}
</div>
}
</div>
)
}