From 7915564d2c20fce21ba3808819e46b3fcd411163 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 13 Jan 2021 16:56:01 -0800 Subject: [PATCH 1/3] Require signup token to register --- server/api/api.go | 50 ++++++- server/api/auth.go | 32 ++++- server/app/auth.go | 5 + server/app/workspaces.go | 53 +++++++ server/model/workspace.go | 9 ++ server/server/server.go | 4 +- server/services/store/mockstore/mockstore.go | 43 ++++++ .../sqlstore/migrations/postgres/bindata.go | 50 ++++++- .../000007_workspaces_table.down.sql | 1 + .../000007_workspaces_table.up.sql | 8 ++ .../sqlstore/migrations/sqlite/bindata.go | 50 ++++++- .../000007_workspaces_table.down.sql | 1 + .../000007_workspaces_table.up.sql | 8 ++ server/services/store/sqlstore/user.go | 16 +++ server/services/store/sqlstore/workspaces.go | 98 +++++++++++++ server/services/store/store.go | 5 + webapp/src/blocks/workspace.ts | 11 ++ webapp/src/components/modal.scss | 8 ++ webapp/src/components/modal.tsx | 5 +- .../components/registrationLinkComponent.scss | 28 ++++ .../components/registrationLinkComponent.tsx | 92 ++++++++++++ .../src/components/shareBoardComponent.scss | 1 + webapp/src/components/sidebar.tsx | 134 ++++++++++-------- webapp/src/octoClient.ts | 33 ++++- webapp/src/pages/registerPage.scss | 3 + webapp/src/pages/registerPage.tsx | 19 ++- 26 files changed, 687 insertions(+), 80 deletions(-) create mode 100644 server/app/workspaces.go create mode 100644 server/model/workspace.go create mode 100644 server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.down.sql create mode 100644 server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.up.sql create mode 100644 server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.down.sql create mode 100644 server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.up.sql create mode 100644 server/services/store/sqlstore/workspaces.go create mode 100644 webapp/src/blocks/workspace.ts create mode 100644 webapp/src/components/registrationLinkComponent.scss create mode 100644 webapp/src/components/registrationLinkComponent.tsx diff --git a/server/api/api.go b/server/api/api.go index c7bb93f57..89d333028 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -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) { diff --git a/server/api/auth.go b/server/api/auth.go index c4ccd2ffb..cb776ad03 100644 --- a/server/api/auth.go +++ b/server/api/auth.go @@ -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) { diff --git a/server/app/auth.go b/server/app/auth.go index 56efbd6da..18f644d12 100644 --- a/server/app/auth.go +++ b/server/app/auth.go @@ -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 { diff --git a/server/app/workspaces.go b/server/app/workspaces.go new file mode 100644 index 000000000..935ad9ffb --- /dev/null +++ b/server/app/workspaces.go @@ -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) +} diff --git a/server/model/workspace.go b/server/model/workspace.go new file mode 100644 index 000000000..5dd104c8c --- /dev/null +++ b/server/model/workspace.go @@ -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"` +} diff --git a/server/server/server.go b/server/server/server.go index 621edf4dc..8b0027cdd 100644 --- a/server/server/server.go +++ b/server/server/server.go @@ -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) diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 7395aaef3..260a738e2 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -313,6 +313,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 +425,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) +} diff --git a/server/services/store/sqlstore/migrations/postgres/bindata.go b/server/services/store/sqlstore/migrations/postgres/bindata.go index 514567b9e..c6e5d54b1 100644 --- a/server/services/store/sqlstore/migrations/postgres/bindata.go +++ b/server/services/store/sqlstore/migrations/postgres/bindata.go @@ -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 diff --git a/server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.down.sql b/server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.down.sql new file mode 100644 index 000000000..1ebc60080 --- /dev/null +++ b/server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.down.sql @@ -0,0 +1 @@ +DROP TABLE workspaces; diff --git a/server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.up.sql b/server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.up.sql new file mode 100644 index 000000000..25483318b --- /dev/null +++ b/server/services/store/sqlstore/migrations/postgres_files/000007_workspaces_table.up.sql @@ -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) +); diff --git a/server/services/store/sqlstore/migrations/sqlite/bindata.go b/server/services/store/sqlstore/migrations/sqlite/bindata.go index 72887c0eb..21279f358 100644 --- a/server/services/store/sqlstore/migrations/sqlite/bindata.go +++ b/server/services/store/sqlstore/migrations/sqlite/bindata.go @@ -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 diff --git a/server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.down.sql b/server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.down.sql new file mode 100644 index 000000000..1ebc60080 --- /dev/null +++ b/server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.down.sql @@ -0,0 +1 @@ +DROP TABLE workspaces; diff --git a/server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.up.sql b/server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.up.sql new file mode 100644 index 000000000..8769b270c --- /dev/null +++ b/server/services/store/sqlstore/migrations/sqlite_files/000007_workspaces_table.up.sql @@ -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) +); diff --git a/server/services/store/sqlstore/user.go b/server/services/store/sqlstore/user.go index 48bb56e07..dcf141d47 100644 --- a/server/services/store/sqlstore/user.go +++ b/server/services/store/sqlstore/user.go @@ -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"). diff --git a/server/services/store/sqlstore/workspaces.go b/server/services/store/sqlstore/workspaces.go new file mode 100644 index 000000000..d92865dd7 --- /dev/null +++ b/server/services/store/sqlstore/workspaces.go @@ -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 +} diff --git a/server/services/store/store.go b/server/services/store/store.go index 5d2ebe93e..4a70169f8 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -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) } diff --git a/webapp/src/blocks/workspace.ts b/webapp/src/blocks/workspace.ts new file mode 100644 index 000000000..478d86f9e --- /dev/null +++ b/webapp/src/blocks/workspace.ts @@ -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> + readonly modifiedBy?: string, + readonly updateAt?: number, +} + +export {IWorkspace} diff --git a/webapp/src/components/modal.scss b/webapp/src/components/modal.scss index 36a501e41..74a0ecd3c 100644 --- a/webapp/src/components/modal.scss +++ b/webapp/src/components/modal.scss @@ -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) { diff --git a/webapp/src/components/modal.tsx b/webapp/src/components/modal.tsx index dc9f5ea71..064b5a309 100644 --- a/webapp/src/components/modal.tsx +++ b/webapp/src/components/modal.tsx @@ -10,6 +10,7 @@ import './modal.scss' type Props = { onClose: () => void intl: IntlShape + position?: 'top'|'bottom' } class Modal extends React.PureComponent { @@ -37,9 +38,11 @@ class Modal extends React.PureComponent { } render(): JSX.Element { + const {position} = this.props + return (
diff --git a/webapp/src/components/registrationLinkComponent.scss b/webapp/src/components/registrationLinkComponent.scss new file mode 100644 index 000000000..9e88c1a9a --- /dev/null +++ b/webapp/src/components/registrationLinkComponent.scss @@ -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; + } +} \ No newline at end of file diff --git a/webapp/src/components/registrationLinkComponent.tsx b/webapp/src/components/registrationLinkComponent.tsx new file mode 100644 index 000000000..be8edc6b4 --- /dev/null +++ b/webapp/src/components/registrationLinkComponent.tsx @@ -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 { + 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 ( + +
+ {workspace && <> +
+ + +
+
+ +
+ } +
+
+ ) + } + + 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) diff --git a/webapp/src/components/shareBoardComponent.scss b/webapp/src/components/shareBoardComponent.scss index 5c1ce6850..0fa92e958 100644 --- a/webapp/src/components/shareBoardComponent.scss +++ b/webapp/src/components/shareBoardComponent.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; padding: 5px; + color: rgb(var(--main-fg)); > .row { display: flex; diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index df8142364..8b7693131 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -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 { @@ -263,63 +267,79 @@ class Sidebar extends React.Component { - - + + { + this.setState({showRegistrationLinkDialog: true}) + }} + /> + Archiver.importFullArchive()} + /> + Archiver.exportFullArchive()} + /> + + this.props.setLanguage('en')} + /> + this.props.setLanguage('es')} + /> + + + setTheme(defaultTheme)} + /> + setTheme(darkTheme)} + /> + setTheme(lightTheme)} + /> + + + + {this.state.showRegistrationLinkDialog && + { + this.setState({showRegistrationLinkDialog: false}) + }} /> - - - Archiver.importFullArchive()} - /> - Archiver.exportFullArchive()} - /> - - this.props.setLanguage('en')} - /> - this.props.setLanguage('es')} - /> - - - setTheme(defaultTheme)} - /> - setTheme(darkTheme)} - /> - setTheme(lightTheme)} - /> - - - + } +
) } diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index ede454b1a..8284e8a55 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -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 { + 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 { + 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 { + 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 { diff --git a/webapp/src/pages/registerPage.scss b/webapp/src/pages/registerPage.scss index bb013d303..4cdbb4a29 100644 --- a/webapp/src/pages/registerPage.scss +++ b/webapp/src/pages/registerPage.scss @@ -24,4 +24,7 @@ .Button { margin-top: 10px; } + .error { + color: #900000; + } } diff --git a/webapp/src/pages/registerPage.tsx b/webapp/src/pages/registerPage.tsx index 79adbc8b5..b51bf83a0 100644 --- a/webapp/src/pages/registerPage.tsx +++ b/webapp/src/pages/registerPage.tsx @@ -18,22 +18,30 @@ type State = { email: string username: string password: string + errorMessage?: string } class RegisterPage extends React.PureComponent { - state = { + state: State = { email: '', username: '', password: '', } private handleRegister = async (): Promise => { - 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 {
{'or login if you already have an account'} + {this.state.errorMessage && +
+ {this.state.errorMessage} +
+ } ) } From 0598aaff2d5859b2e8893f159d55a4ab4e2476c7 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 13 Jan 2021 17:02:46 -0800 Subject: [PATCH 2/3] i18n-extract --- webapp/i18n/en.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 8315eb7f7..e20fb1fef 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -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", From ed2ff2d6b03842fdf556aa33f303c9710139e627 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 13 Jan 2021 17:09:43 -0800 Subject: [PATCH 3/3] make generate --- server/services/store/mockstore/mockstore.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 260a738e2..1ccf5a012 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -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()