mirror of
https://github.com/mattermost/focalboard.git
synced 2024-11-24 08:22:29 +02:00
Restoring guest account access and adding backend part of the guest accounts support (#2929)
Co-authored-by: Paul Esch-Laurent <paul.esch-laurent@mattermost.com> Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
parent
e1a39e57ff
commit
61a8af8f34
4
Makefile
4
Makefile
@ -100,12 +100,12 @@ server-linux-package-docker:
|
||||
rm -rf package
|
||||
|
||||
generate: ## Install and run code generators.
|
||||
cd server; go get -modfile=go.tools.mod github.com/golang/mock/mockgen
|
||||
cd server; go get github.com/golang/mock/mockgen
|
||||
cd server; go generate ./...
|
||||
|
||||
server-lint: templates-archive ## Run linters on server code.
|
||||
@if ! [ -x "$$(command -v golangci-lint)" ]; then \
|
||||
echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install for installation instructions."; \
|
||||
echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install-golangci-lint for installation instructions."; \
|
||||
exit 1; \
|
||||
fi;
|
||||
cd server; golangci-lint run ./...
|
||||
|
@ -75,7 +75,7 @@ endif
|
||||
|
||||
ifneq ($(HAS_SERVER),)
|
||||
@if ! [ -x "$$(command -v golangci-lint)" ]; then \
|
||||
echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install for installation instructions."; \
|
||||
echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install-golangci-lint for installation instructions."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
|
||||
|
@ -165,6 +165,13 @@ func (a *API) hasValidReadTokenForBoard(r *http.Request, boardID string) bool {
|
||||
return isValid
|
||||
}
|
||||
|
||||
func (a *API) userIsGuest(userID string) (bool, error) {
|
||||
if a.singleUserToken != "" {
|
||||
return false, nil
|
||||
}
|
||||
return a.app.UserIsGuest(userID)
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message string, sourceError error) {
|
||||
|
@ -134,6 +134,16 @@ func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to create board"})
|
||||
return
|
||||
}
|
||||
|
||||
file, handle, err := r.FormFile(UploadFormFileKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
@ -206,7 +216,13 @@ func (a *API) handleArchiveExportTeam(w http.ResponseWriter, r *http.Request) {
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("TeamID", teamID)
|
||||
|
||||
boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID)
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
|
@ -381,17 +381,6 @@ func (a *API) attachSession(handler func(w http.ResponseWriter, r *http.Request)
|
||||
UpdateAt: now,
|
||||
}
|
||||
|
||||
user, err := a.app.GetUser(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
if user.IsGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "guests not supported", nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||
handler(w, r.WithContext(ctx))
|
||||
return
|
||||
|
@ -104,6 +104,19 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if board.IsTemplate {
|
||||
var isGuest bool
|
||||
isGuest, err = a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"guest are not allowed to get board templates"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getBlocks", audit.Fail)
|
||||
|
@ -65,8 +65,14 @@ func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) {
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID)
|
||||
boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -143,6 +149,16 @@ func (a *API) handleCreateBoard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to create board"})
|
||||
return
|
||||
}
|
||||
|
||||
if err = newBoard.IsValid(); err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
@ -233,6 +249,19 @@ func (a *API) handleGetBoard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var isGuest bool
|
||||
isGuest, err = a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
|
||||
return
|
||||
@ -502,6 +531,16 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to create board"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "duplicateBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
@ -98,6 +98,16 @@ func (a *API) handleCreateBoardsAndBlocks(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to create board"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range newBab.Blocks {
|
||||
// Error checking
|
||||
if len(block.Type) < 1 {
|
||||
|
@ -246,6 +246,16 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"guests not allowed to join boards"})
|
||||
return
|
||||
}
|
||||
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
@ -421,6 +431,16 @@ func (a *API) handleUpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||
SchemeViewer: reqBoardMember.SchemeViewer,
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(paramsUserID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isGuest {
|
||||
newBoardMember.SchemeAdmin = false
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modify board members"})
|
||||
return
|
||||
|
@ -53,6 +53,16 @@ func (a *API) handleOnboard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to create board"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, boardID, err := a.app.PrepareOnboardingTour(userID, teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
|
@ -146,8 +146,14 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -299,8 +305,14 @@ func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID)
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
|
@ -226,7 +226,17 @@ func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec := a.makeAuditRecord(r, "getUsers", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
users, err := a.app.SearchTeamUsers(teamID, searchQuery)
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
asGuestUser := ""
|
||||
if isGuest {
|
||||
asGuestUser = userID
|
||||
}
|
||||
|
||||
users, err := a.app.SearchTeamUsers(teamID, searchQuery, asGuestUser)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "searchQuery="+searchQuery, err)
|
||||
return
|
||||
|
@ -51,6 +51,16 @@ func (a *API) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to templates"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getTemplates", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
@ -219,6 +219,19 @@ func (a *API) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
canSeeUser, err := a.app.CanSeeUser(session.UserID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
if !canSeeUser {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", nil)
|
||||
return
|
||||
}
|
||||
|
||||
userData, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
|
@ -256,8 +256,8 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
|
||||
return bab, members, err
|
||||
}
|
||||
|
||||
func (a *App) GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, error) {
|
||||
return a.store.GetBoardsForUserAndTeam(userID, teamID)
|
||||
func (a *App) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.GetBoardsForUserAndTeam(userID, teamID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) {
|
||||
@ -552,8 +552,8 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUser(term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, userID)
|
||||
func (a *App) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, userID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
|
@ -65,7 +65,7 @@ func (a *App) GetUserTimezone(userID string) (string, error) {
|
||||
|
||||
func getUserBoards(userID string, teamID string, a *App) ([]string, error) {
|
||||
// get boards accessible by user and filter boardIDs
|
||||
boards, err := a.store.GetBoardsForUserAndTeam(userID, teamID)
|
||||
boards, err := a.store.GetBoardsForUserAndTeam(userID, teamID, true)
|
||||
if err != nil {
|
||||
return nil, errors.New("error getting boards for user")
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func TestGetTeamAndUserBoardsInsights(t *testing.T) {
|
||||
IsGuest: false,
|
||||
}
|
||||
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id").Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().
|
||||
GetTeamBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(mockTeamInsightsList, nil)
|
||||
@ -72,7 +72,7 @@ func TestGetTeamAndUserBoardsInsights(t *testing.T) {
|
||||
IsGuest: false,
|
||||
}
|
||||
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id").Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().
|
||||
GetTeamBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(nil, insightError{"board-insight-error"})
|
||||
|
@ -5,12 +5,12 @@ import (
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamUsers(teamID string) ([]*model.User, error) {
|
||||
return a.store.GetUsersByTeam(teamID)
|
||||
func (a *App) GetTeamUsers(teamID string, asGuestID string) ([]*model.User, error) {
|
||||
return a.store.GetUsersByTeam(teamID, asGuestID)
|
||||
}
|
||||
|
||||
func (a *App) SearchTeamUsers(teamID string, searchQuery string) ([]*model.User, error) {
|
||||
return a.store.SearchUsersByTeam(teamID, searchQuery)
|
||||
func (a *App) SearchTeamUsers(teamID string, searchQuery string, asGuestID string) ([]*model.User, error) {
|
||||
return a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID)
|
||||
}
|
||||
|
||||
func (a *App) UpdateUserConfig(userID string, patch model.UserPropPatch) (map[string]interface{}, error) {
|
||||
@ -26,6 +26,29 @@ func (a *App) UpdateUserConfig(userID string, patch model.UserPropPatch) (map[st
|
||||
return user.Props, nil
|
||||
}
|
||||
|
||||
func (a *App) UserIsGuest(userID string) (bool, error) {
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return user.IsGuest, nil
|
||||
}
|
||||
|
||||
func (a *App) CanSeeUser(seerUser string, seenUser string) (bool, error) {
|
||||
isGuest, err := a.UserIsGuest(seerUser)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if isGuest {
|
||||
hasSharedChannels, err := a.store.CanSeeUser(seerUser, seenUser)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hasSharedChannels, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) {
|
||||
return a.store.SearchUserChannels(teamID, userID, query)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
//go:generate mockgen --build_flags=--mod=mod -destination=mocks/mockauth_interface.go -package mocks . AuthInterface
|
||||
//go:generate mockgen -destination=mocks/mockauth_interface.go -package mocks . AuthInterface
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
@ -263,7 +263,7 @@ func TestCreateBoard(t *testing.T) {
|
||||
th.CheckBadRequest(resp)
|
||||
require.Nil(t, board)
|
||||
|
||||
boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID)
|
||||
boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, boards)
|
||||
})
|
||||
@ -277,7 +277,7 @@ func TestCreateBoard(t *testing.T) {
|
||||
th.CheckBadRequest(resp)
|
||||
require.Nil(t, board)
|
||||
|
||||
boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID)
|
||||
boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, boards)
|
||||
})
|
||||
@ -292,7 +292,7 @@ func TestCreateBoard(t *testing.T) {
|
||||
th.CheckForbidden(resp)
|
||||
require.Nil(t, board)
|
||||
|
||||
boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID)
|
||||
boards, err := th.Server.App().GetBoardsForUserAndTeam(user1.ID, teamID, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, boards)
|
||||
})
|
||||
@ -530,7 +530,7 @@ func TestSearchBoards(t *testing.T) {
|
||||
Type: model.BoardTypePrivate,
|
||||
TeamID: "other-team-id",
|
||||
}
|
||||
_, err = th.Server.App().CreateBoard(board5, user1.ID, true)
|
||||
rBoard5, err := th.Server.App().CreateBoard(board5, user1.ID, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
@ -543,13 +543,13 @@ func TestSearchBoards(t *testing.T) {
|
||||
Name: "should return all boards where user1 is member or that are public",
|
||||
Client: th.Client,
|
||||
Term: "board",
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID},
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID, rBoard5.ID},
|
||||
},
|
||||
{
|
||||
Name: "matching a full word",
|
||||
Client: th.Client,
|
||||
Term: "admin",
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard3.ID},
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard3.ID, rBoard5.ID},
|
||||
},
|
||||
{
|
||||
Name: "matching part of the word",
|
||||
@ -1595,6 +1595,52 @@ func TestUpdateMember(t *testing.T) {
|
||||
require.Len(t, members, 1)
|
||||
require.True(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("should always disable the admin role on update member if the user is a guest", func(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupClients(th)
|
||||
|
||||
newBoard := &model.Board{
|
||||
Title: "title",
|
||||
Type: model.BoardTypeOpen,
|
||||
TeamID: teamID,
|
||||
}
|
||||
board, err := th.Server.App().CreateBoard(newBoard, userAdmin, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
newGuestMember := &model.BoardMember{
|
||||
UserID: userGuest,
|
||||
BoardID: board.ID,
|
||||
SchemeViewer: true,
|
||||
SchemeCommenter: true,
|
||||
SchemeEditor: true,
|
||||
SchemeAdmin: false,
|
||||
}
|
||||
guestMember, err := th.Server.App().AddMemberToBoard(newGuestMember)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, guestMember)
|
||||
require.True(t, guestMember.SchemeViewer)
|
||||
require.True(t, guestMember.SchemeCommenter)
|
||||
require.True(t, guestMember.SchemeEditor)
|
||||
require.False(t, guestMember.SchemeAdmin)
|
||||
|
||||
memberUpdate := &model.BoardMember{
|
||||
UserID: userGuest,
|
||||
BoardID: board.ID,
|
||||
SchemeAdmin: true,
|
||||
SchemeViewer: true,
|
||||
SchemeCommenter: true,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
updatedGuestMember, resp := clients.Admin.UpdateBoardMember(memberUpdate)
|
||||
th.CheckOK(resp)
|
||||
require.True(t, updatedGuestMember.SchemeViewer)
|
||||
require.True(t, updatedGuestMember.SchemeCommenter)
|
||||
require.True(t, updatedGuestMember.SchemeEditor)
|
||||
require.False(t, updatedGuestMember.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteMember(t *testing.T) {
|
||||
|
@ -37,6 +37,7 @@ const (
|
||||
userCommenter string = "commenter"
|
||||
userEditor string = "editor"
|
||||
userAdmin string = "admin"
|
||||
userGuest string = "guest"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -47,6 +48,7 @@ var (
|
||||
userCommenterID = userCommenter
|
||||
userEditorID = userEditor
|
||||
userAdminID = userAdmin
|
||||
userGuestID = userGuest
|
||||
)
|
||||
|
||||
type LicenseType int
|
||||
|
@ -54,7 +54,7 @@ func TestExportBoard(t *testing.T) {
|
||||
require.NoError(t, resp.Error)
|
||||
|
||||
// check for test card
|
||||
boardsImported, err := th.Server.App().GetBoardsForUserAndTeam(th.GetUser1().ID, model.GlobalTeamID)
|
||||
boardsImported, err := th.Server.App().GetBoardsForUserAndTeam(th.GetUser1().ID, model.GlobalTeamID, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, boardsImported, 1)
|
||||
boardImported := boardsImported[0]
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -73,6 +73,15 @@ func NewPluginTestStore(innerStore store.Store) *PluginTestStore {
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
},
|
||||
"guest": {
|
||||
ID: "guest",
|
||||
Props: map[string]interface{}{},
|
||||
Username: "guest",
|
||||
Email: "guest@sample.com",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
IsGuest: true,
|
||||
},
|
||||
},
|
||||
testTeam: &model.Team{ID: "test-team", Title: "Test Team"},
|
||||
otherTeam: &model.Team{ID: "other-team", Title: "Other Team"},
|
||||
@ -109,6 +118,8 @@ func (s *PluginTestStore) GetTeamsForUser(userID string) ([]*model.Team, error)
|
||||
return []*model.Team{s.testTeam, s.otherTeam}, nil
|
||||
case "admin":
|
||||
return []*model.Team{s.testTeam, s.otherTeam}, nil
|
||||
case "guest":
|
||||
return []*model.Team{s.testTeam}, nil
|
||||
}
|
||||
return nil, errTestStore
|
||||
}
|
||||
@ -160,7 +171,16 @@ func (s *PluginTestStore) PatchUserProps(userID string, patch model.UserPropPatc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) GetUsersByTeam(teamID string) ([]*model.User, error) {
|
||||
func (s *PluginTestStore) GetUsersByTeam(teamID string, asGuestID string) ([]*model.User, error) {
|
||||
if asGuestID == "guest" {
|
||||
return []*model.User{
|
||||
s.users["viewer"],
|
||||
s.users["commenter"],
|
||||
s.users["editor"],
|
||||
s.users["admin"],
|
||||
s.users["guest"],
|
||||
}, nil
|
||||
}
|
||||
switch {
|
||||
case teamID == s.testTeam.ID:
|
||||
return []*model.User{
|
||||
@ -169,6 +189,7 @@ func (s *PluginTestStore) GetUsersByTeam(teamID string) ([]*model.User, error) {
|
||||
s.users["commenter"],
|
||||
s.users["editor"],
|
||||
s.users["admin"],
|
||||
s.users["guest"],
|
||||
}, nil
|
||||
case teamID == s.otherTeam.ID:
|
||||
return []*model.User{
|
||||
@ -184,9 +205,9 @@ func (s *PluginTestStore) GetUsersByTeam(teamID string) ([]*model.User, error) {
|
||||
return nil, errTestStore
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) SearchUsersByTeam(teamID string, searchQuery string) ([]*model.User, error) {
|
||||
func (s *PluginTestStore) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string) ([]*model.User, error) {
|
||||
users := []*model.User{}
|
||||
teamUsers, err := s.GetUsersByTeam(teamID)
|
||||
teamUsers, err := s.GetUsersByTeam(teamID, asGuestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -199,6 +220,32 @@ func (s *PluginTestStore) SearchUsersByTeam(teamID string, searchQuery string) (
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) CanSeeUser(seerID string, seenID string) (bool, error) {
|
||||
user, err := s.GetUserByID(seerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !user.IsGuest {
|
||||
return true, nil
|
||||
}
|
||||
seerMembers, err := s.GetMembersForUser(seerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
seenMembers, err := s.GetMembersForUser(seenID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, seerMember := range seerMembers {
|
||||
for _, seenMember := range seenMembers {
|
||||
if seerMember.BoardID == seenMember.BoardID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) {
|
||||
return []*mmModel.Channel{
|
||||
{
|
||||
@ -235,8 +282,8 @@ func (s *PluginTestStore) GetChannel(teamID, channel string) (*mmModel.Channel,
|
||||
return nil, errTestStore
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) SearchBoardsForUser(term string, userID string) ([]*model.Board, error) {
|
||||
boards, err := s.Store.SearchBoardsForUser(term, userID)
|
||||
func (s *PluginTestStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
boards, err := s.Store.SearchBoardsForUser(term, userID, includePublicBoards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
//go:generate mockgen --build_flags=--mod=mod -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
|
||||
//go:generate mockgen -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
|
||||
package mmpermissions
|
||||
|
||||
import (
|
||||
|
@ -1,4 +1,4 @@
|
||||
//go:generate mockgen --build_flags=--mod=mod -destination=mocks/mockstore.go -package mocks . Store
|
||||
//go:generate mockgen -destination=mocks/mockstore.go -package mocks . Store
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
|
@ -71,8 +71,7 @@ func (s *MattermostAuthLayer) GetRegisteredUserCount() (int, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("count(*)").
|
||||
From("Users").
|
||||
Where(sq.Eq{"deleteAt": 0}).
|
||||
Where(sq.NotEq{"roles": "system_guest"})
|
||||
Where(sq.Eq{"deleteAt": 0})
|
||||
row := query.QueryRow()
|
||||
|
||||
var count int
|
||||
@ -267,16 +266,32 @@ func (s *MattermostAuthLayer) getQueryBuilder() sq.StatementBuilderType {
|
||||
return builder.RunWith(s.mmDB)
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetUsersByTeam(teamID string) ([]*model.User, error) {
|
||||
func (s *MattermostAuthLayer) GetUsersByTeam(teamID string, asGuestID string) ([]*model.User, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("u.id", "u.username", "u.email", "u.nickname", "u.firstname", "u.lastname", "u.props", "u.CreateAt as create_at", "u.UpdateAt as update_at",
|
||||
"u.DeleteAt as delete_at", "b.UserId IS NOT NULL AS is_bot").
|
||||
"u.DeleteAt as delete_at", "b.UserId IS NOT NULL AS is_bot, u.roles = 'system_guest' as is_guest").
|
||||
From("Users as u").
|
||||
Join("TeamMembers as tm ON tm.UserID = u.id").
|
||||
LeftJoin("Bots b ON ( b.UserID = u.id )").
|
||||
Where(sq.Eq{"u.deleteAt": 0}).
|
||||
Where(sq.NotEq{"u.roles": "system_guest"}).
|
||||
Where(sq.Eq{"u.deleteAt": 0})
|
||||
|
||||
if asGuestID == "" {
|
||||
query = query.
|
||||
Join("TeamMembers as tm ON tm.UserID = u.id").
|
||||
Where(sq.Eq{"tm.TeamId": teamID})
|
||||
} else {
|
||||
boards, err := s.GetBoardsForUserAndTeam(asGuestID, teamID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardsIDs := []string{}
|
||||
for _, board := range boards {
|
||||
boardsIDs = append(boardsIDs, board.ID)
|
||||
}
|
||||
query = query.
|
||||
Join(s.tablePrefix + "board_members as bm ON bm.UserID = u.ID").
|
||||
Where(sq.Eq{"bm.BoardId": boardsIDs})
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
@ -314,12 +329,11 @@ func (s *MattermostAuthLayer) GetUsersList(userIDs []string) ([]*model.User, err
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery string) ([]*model.User, error) {
|
||||
func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string) ([]*model.User, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("u.id", "u.username", "u.email", "u.nickname", "u.firstname", "u.lastname", "u.props", "u.CreateAt as create_at", "u.UpdateAt as update_at",
|
||||
"u.DeleteAt as delete_at", "b.UserId IS NOT NULL AS is_bot").
|
||||
"u.DeleteAt as delete_at", "b.UserId IS NOT NULL AS is_bot, u.roles = 'system_guest' as is_guest").
|
||||
From("Users as u").
|
||||
Join("TeamMembers as tm ON tm.UserID = u.id").
|
||||
LeftJoin("Bots b ON ( b.UserId = u.id )").
|
||||
Where(sq.Eq{"u.deleteAt": 0}).
|
||||
Where(sq.Or{
|
||||
@ -328,11 +342,27 @@ func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery strin
|
||||
sq.Like{"u.firstname": "%" + searchQuery + "%"},
|
||||
sq.Like{"u.lastname": "%" + searchQuery + "%"},
|
||||
}).
|
||||
Where(sq.Eq{"tm.TeamId": teamID}).
|
||||
Where(sq.NotEq{"u.roles": "system_guest"}).
|
||||
OrderBy("u.username").
|
||||
Limit(10)
|
||||
|
||||
if asGuestID == "" {
|
||||
query = query.
|
||||
Join("TeamMembers as tm ON tm.UserID = u.id").
|
||||
Where(sq.Eq{"tm.TeamId": teamID})
|
||||
} else {
|
||||
boards, err := s.GetBoardsForUserAndTeam(asGuestID, teamID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardsIDs := []string{}
|
||||
for _, board := range boards {
|
||||
boardsIDs = append(boardsIDs, board.ID)
|
||||
}
|
||||
query = query.
|
||||
Join(s.tablePrefix + "board_members as bm ON bm.UserID = u.ID").
|
||||
Where(sq.Eq{"bm.BoardId": boardsIDs})
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -366,6 +396,7 @@ func (s *MattermostAuthLayer) usersFromRows(rows *sql.Rows) ([]*model.User, erro
|
||||
&user.UpdateAt,
|
||||
&user.DeleteAt,
|
||||
&user.IsBot,
|
||||
&user.IsGuest,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -558,7 +589,7 @@ func boardFields(prefix string) []string {
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string) ([]*model.Board, error) {
|
||||
func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
@ -568,17 +599,20 @@ func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string) ([]*model
|
||||
LeftJoin("ChannelMembers as cm on cm.channelId=b.channel_id").
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Eq{"tm.userID": userID}).
|
||||
Where(sq.Eq{"tm.deleteAt": 0}).
|
||||
Where(sq.Or{
|
||||
Where(sq.Eq{"tm.deleteAt": 0})
|
||||
|
||||
if includePublicBoards {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.And{
|
||||
sq.Eq{"b.type": model.BoardTypePrivate},
|
||||
sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
sq.Eq{"cm.userId": userID},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
sq.Eq{"cm.userId": userID},
|
||||
})
|
||||
}
|
||||
|
||||
if term != "" {
|
||||
// break search query into space separated words
|
||||
@ -799,12 +833,14 @@ func (s *MattermostAuthLayer) GetMembersForBoard(boardID string) ([]*model.Board
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, error) {
|
||||
func (s *MattermostAuthLayer) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
members, err := s.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: Handle the includePublicBoards
|
||||
|
||||
boardIDs := []string{}
|
||||
for _, m := range members {
|
||||
boardIDs = append(boardIDs, m.BoardID)
|
||||
@ -919,3 +955,67 @@ func (s *MattermostAuthLayer) GetUserTimezone(userID string) (string, error) {
|
||||
timezone := user.Timezone
|
||||
return mmModel.GetPreferredTimezone(timezone), nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) CanSeeUser(seerID string, seenID string) (bool, error) {
|
||||
mmuser, appErr := s.servicesAPI.GetUserByID(seerID)
|
||||
if appErr != nil {
|
||||
return false, appErr
|
||||
}
|
||||
if !mmuser.IsGuest() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Select("1").
|
||||
From(s.tablePrefix + "board_members AS BM1").
|
||||
Join(s.tablePrefix + "board_members AS BM2 ON BM1.BoardID=BM2.BoardID").
|
||||
LeftJoin("Bots b ON ( b.UserId = u.id )").
|
||||
Where(sq.Or{
|
||||
sq.And{
|
||||
sq.Eq{"BM1.UserID": seerID},
|
||||
sq.Eq{"BM2.UserID": seenID},
|
||||
},
|
||||
sq.And{
|
||||
sq.Eq{"BM1.UserID": seenID},
|
||||
sq.Eq{"BM2.UserID": seerID},
|
||||
},
|
||||
}).Limit(1)
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
for rows.Next() {
|
||||
return true, err
|
||||
}
|
||||
|
||||
query = s.getQueryBuilder().
|
||||
Select("1").
|
||||
From("ChannelMembers AS CM1").
|
||||
Join("ChannelMembers AS CM2 ON CM1.BoardID=CM2.BoardID").
|
||||
LeftJoin("Bots b ON ( b.UserId = u.id )").
|
||||
Where(sq.Or{
|
||||
sq.And{
|
||||
sq.Eq{"CM1.UserID": seerID},
|
||||
sq.Eq{"CM2.UserID": seenID},
|
||||
},
|
||||
sq.And{
|
||||
sq.Eq{"CM1.UserID": seenID},
|
||||
sq.Eq{"CM2.UserID": seerID},
|
||||
},
|
||||
}).Limit(1)
|
||||
|
||||
rows, err = query.Query()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
for rows.Next() {
|
||||
return true, err
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
@ -50,6 +50,21 @@ func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1, arg2 interfa
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// CanSeeUser mocks base method.
|
||||
func (m *MockStore) CanSeeUser(arg0, arg1 string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CanSeeUser", arg0, arg1)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CanSeeUser indicates an expected call of CanSeeUser.
|
||||
func (mr *MockStoreMockRecorder) CanSeeUser(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanSeeUser", reflect.TypeOf((*MockStore)(nil).CanSeeUser), arg0, arg1)
|
||||
}
|
||||
|
||||
// CleanUpSessions mocks base method.
|
||||
func (m *MockStore) CleanUpSessions(arg0 int64) error {
|
||||
m.ctrl.T.Helper()
|
||||
@ -552,18 +567,18 @@ func (mr *MockStoreMockRecorder) GetBoardMemberHistory(arg0, arg1, arg2 interfac
|
||||
}
|
||||
|
||||
// GetBoardsForUserAndTeam mocks base method.
|
||||
func (m *MockStore) GetBoardsForUserAndTeam(arg0, arg1 string) ([]*model.Board, error) {
|
||||
func (m *MockStore) GetBoardsForUserAndTeam(arg0, arg1 string, arg2 bool) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetBoardsForUserAndTeam", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "GetBoardsForUserAndTeam", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetBoardsForUserAndTeam indicates an expected call of GetBoardsForUserAndTeam.
|
||||
func (mr *MockStoreMockRecorder) GetBoardsForUserAndTeam(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) GetBoardsForUserAndTeam(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).GetBoardsForUserAndTeam), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).GetBoardsForUserAndTeam), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// GetBoardsInTeamByIds mocks base method.
|
||||
@ -1076,18 +1091,18 @@ func (mr *MockStoreMockRecorder) GetUserTimezone(arg0 interface{}) *gomock.Call
|
||||
}
|
||||
|
||||
// GetUsersByTeam mocks base method.
|
||||
func (m *MockStore) GetUsersByTeam(arg0 string) ([]*model.User, error) {
|
||||
func (m *MockStore) GetUsersByTeam(arg0, arg1 string) ([]*model.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUsersByTeam", arg0)
|
||||
ret := m.ctrl.Call(m, "GetUsersByTeam", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*model.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUsersByTeam indicates an expected call of GetUsersByTeam.
|
||||
func (mr *MockStoreMockRecorder) GetUsersByTeam(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) GetUsersByTeam(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByTeam", reflect.TypeOf((*MockStore)(nil).GetUsersByTeam), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByTeam", reflect.TypeOf((*MockStore)(nil).GetUsersByTeam), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetUsersList mocks base method.
|
||||
@ -1323,18 +1338,18 @@ func (mr *MockStoreMockRecorder) SaveMember(arg0 interface{}) *gomock.Call {
|
||||
}
|
||||
|
||||
// SearchBoardsForUser mocks base method.
|
||||
func (m *MockStore) SearchBoardsForUser(arg0, arg1 string) ([]*model.Board, error) {
|
||||
func (m *MockStore) SearchBoardsForUser(arg0, arg1 string, arg2 bool) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchBoardsForUser", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "SearchBoardsForUser", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchBoardsForUser indicates an expected call of SearchBoardsForUser.
|
||||
func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SearchBoardsForUserInTeam mocks base method.
|
||||
@ -1368,18 +1383,18 @@ func (mr *MockStoreMockRecorder) SearchUserChannels(arg0, arg1, arg2 interface{}
|
||||
}
|
||||
|
||||
// SearchUsersByTeam mocks base method.
|
||||
func (m *MockStore) SearchUsersByTeam(arg0, arg1 string) ([]*model.User, error) {
|
||||
func (m *MockStore) SearchUsersByTeam(arg0, arg1, arg2 string) ([]*model.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchUsersByTeam", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "SearchUsersByTeam", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]*model.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchUsersByTeam indicates an expected call of SearchUsersByTeam.
|
||||
func (mr *MockStoreMockRecorder) SearchUsersByTeam(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) SearchUsersByTeam(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsersByTeam", reflect.TypeOf((*MockStore)(nil).SearchUsersByTeam), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsersByTeam", reflect.TypeOf((*MockStore)(nil).SearchUsersByTeam), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SendMessage mocks base method.
|
||||
|
@ -252,21 +252,25 @@ func (s *SQLStore) getBoard(db sq.BaseRunner, boardID string) (*model.Board, err
|
||||
return s.getBoardByCondition(db, sq.Eq{"id": boardID})
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsForUserAndTeam(db sq.BaseRunner, userID, teamID string) ([]*model.Board, error) {
|
||||
func (s *SQLStore) getBoardsForUserAndTeam(db sq.BaseRunner, userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{"b.team_id": teamID}).
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Or{
|
||||
Where(sq.Eq{"b.is_template": false})
|
||||
|
||||
if includePublicBoards {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.And{
|
||||
sq.Eq{"b.type": model.BoardTypePrivate},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
})
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
@ -643,20 +647,24 @@ func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*mode
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string) ([]*model.Board, error) {
|
||||
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Or{
|
||||
Where(sq.Eq{"b.is_template": false})
|
||||
|
||||
if includePublicBoards {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.And{
|
||||
sq.Eq{"b.type": model.BoardTypePrivate},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
})
|
||||
}
|
||||
|
||||
if term != "" {
|
||||
// break search query into space separated words
|
||||
|
@ -46,6 +46,11 @@ func (s *SQLStore) AddUpdateCategoryBoard(userID string, categoryID string, bloc
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) CanSeeUser(seerID string, seenID string) (bool, error) {
|
||||
return s.canSeeUser(s.db, seerID, seenID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) CleanUpSessions(expireTime int64) error {
|
||||
return s.cleanUpSessions(s.db, expireTime)
|
||||
|
||||
@ -344,8 +349,8 @@ func (s *SQLStore) GetBoardMemberHistory(boardID string, userID string, limit ui
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string) ([]*model.Board, error) {
|
||||
return s.getBoardsForUserAndTeam(s.db, userID, teamID)
|
||||
func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return s.getBoardsForUserAndTeam(s.db, userID, teamID, includePublicBoards)
|
||||
|
||||
}
|
||||
|
||||
@ -519,8 +524,8 @@ func (s *SQLStore) GetUserTimezone(userID string) (string, error) {
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUsersByTeam(teamID string) ([]*model.User, error) {
|
||||
return s.getUsersByTeam(s.db, teamID)
|
||||
func (s *SQLStore) GetUsersByTeam(teamID string, asGuestID string) ([]*model.User, error) {
|
||||
return s.getUsersByTeam(s.db, teamID, asGuestID)
|
||||
|
||||
}
|
||||
|
||||
@ -756,8 +761,8 @@ func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchBoardsForUser(term string, userID string) ([]*model.Board, error) {
|
||||
return s.searchBoardsForUser(s.db, term, userID)
|
||||
func (s *SQLStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return s.searchBoardsForUser(s.db, term, userID, includePublicBoards)
|
||||
|
||||
}
|
||||
|
||||
@ -771,8 +776,8 @@ func (s *SQLStore) SearchUserChannels(teamID string, userID string, query string
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchUsersByTeam(teamID string, searchQuery string) ([]*model.User, error) {
|
||||
return s.searchUsersByTeam(s.db, teamID, searchQuery)
|
||||
func (s *SQLStore) SearchUsersByTeam(teamID string, searchQuery string, asGuestID string) ([]*model.User, error) {
|
||||
return s.searchUsersByTeam(s.db, teamID, searchQuery, asGuestID)
|
||||
|
||||
}
|
||||
|
||||
|
@ -211,11 +211,11 @@ func (s *SQLStore) updateUserPasswordByID(db sq.BaseRunner, userID, password str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getUsersByTeam(db sq.BaseRunner, _ string) ([]*model.User, error) {
|
||||
func (s *SQLStore) getUsersByTeam(db sq.BaseRunner, _ string, _ string) ([]*model.User, error) {
|
||||
return s.getUsersByCondition(db, nil, 0)
|
||||
}
|
||||
|
||||
func (s *SQLStore) searchUsersByTeam(db sq.BaseRunner, _ string, searchQuery string) ([]*model.User, error) {
|
||||
func (s *SQLStore) searchUsersByTeam(db sq.BaseRunner, _ string, searchQuery string, _ string) ([]*model.User, error) {
|
||||
return s.getUsersByCondition(db, &sq.Like{"username": "%" + searchQuery + "%"}, 10)
|
||||
}
|
||||
|
||||
@ -275,6 +275,10 @@ func (s *SQLStore) patchUserProps(db sq.BaseRunner, userID string, patch model.U
|
||||
return s.updateUser(db, user)
|
||||
}
|
||||
|
||||
func (s *SQLStore) canSeeUser(db sq.BaseRunner, seerID string, seenID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) sendMessage(db sq.BaseRunner, message, postType string, receipts []string) error {
|
||||
return errUnsupportedOperation
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
//go:generate mockgen --build_flags=--mod=mod -destination=mockstore/mockstore.go -package mockstore . Store
|
||||
//go:generate mockgen -destination=mockstore/mockstore.go -package mockstore . Store
|
||||
//go:generate go run ./generators/main.go
|
||||
package store
|
||||
|
||||
@ -61,8 +61,8 @@ type Store interface {
|
||||
UpdateUser(user *model.User) error
|
||||
UpdateUserPassword(username, password string) error
|
||||
UpdateUserPasswordByID(userID, password string) error
|
||||
GetUsersByTeam(teamID string) ([]*model.User, error)
|
||||
SearchUsersByTeam(teamID string, searchQuery string) ([]*model.User, error)
|
||||
GetUsersByTeam(teamID string, asGuestID string) ([]*model.User, error)
|
||||
SearchUsersByTeam(teamID string, searchQuery string, asGuestID string) ([]*model.User, error)
|
||||
PatchUserProps(userID string, patch model.UserPropPatch) error
|
||||
|
||||
GetActiveUserCount(updatedSecondsAgo int64) (int, error)
|
||||
@ -89,7 +89,7 @@ type Store interface {
|
||||
// @withTransaction
|
||||
PatchBoard(boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error)
|
||||
GetBoard(id string) (*model.Board, error)
|
||||
GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, error)
|
||||
GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error)
|
||||
GetBoardsInTeamByIds(boardIDs []string, teamID string) ([]*model.Board, error)
|
||||
// @withTransaction
|
||||
DeleteBoard(boardID, userID string) error
|
||||
@ -100,7 +100,8 @@ type Store interface {
|
||||
GetBoardMemberHistory(boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error)
|
||||
GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
|
||||
GetMembersForUser(userID string) ([]*model.BoardMember, error)
|
||||
SearchBoardsForUser(term, userID string) ([]*model.Board, error)
|
||||
CanSeeUser(seerID string, seenID string) (bool, error)
|
||||
SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error)
|
||||
SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error)
|
||||
|
||||
// @withTransaction
|
||||
|
@ -68,8 +68,8 @@ func getBoardsInsightsTest(t *testing.T, store store.Store) {
|
||||
|
||||
_, _ = store.SaveMember(bm)
|
||||
|
||||
boardsUser1, _ := store.GetBoardsForUserAndTeam(testUserID, testTeamID)
|
||||
boardsUser2, _ := store.GetBoardsForUserAndTeam(testInsightsUserID1, testTeamID)
|
||||
boardsUser1, _ := store.GetBoardsForUserAndTeam(testUserID, testTeamID, true)
|
||||
boardsUser2, _ := store.GetBoardsForUserAndTeam(testInsightsUserID1, testTeamID, true)
|
||||
t.Run("team insights", func(t *testing.T) {
|
||||
boardIDs := []string{boardsUser1[0].ID, boardsUser1[1].ID, boardsUser1[2].ID}
|
||||
topTeamBoards, err := store.GetTeamBoardsInsights(testTeamID, testUserID,
|
||||
|
@ -168,7 +168,7 @@ func testGetBoardsForUserAndTeam(t *testing.T, store store.Store) {
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should only find the two boards that the user is a member of for team 1 plus the one open board", func(t *testing.T) {
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID1)
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID1, true)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []*model.Board{
|
||||
rBoard1,
|
||||
@ -177,8 +177,17 @@ func testGetBoardsForUserAndTeam(t *testing.T, store store.Store) {
|
||||
}, boards)
|
||||
})
|
||||
|
||||
t.Run("should only find the two boards that the user is a member of for team 1", func(t *testing.T) {
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID1, false)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []*model.Board{
|
||||
rBoard1,
|
||||
rBoard2,
|
||||
}, boards)
|
||||
})
|
||||
|
||||
t.Run("should only find the board that the user is a member of for team 2", func(t *testing.T) {
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID2)
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID2, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, boards, 1)
|
||||
require.Equal(t, board5.ID, boards[0].ID)
|
||||
@ -688,7 +697,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
userID := "user-id-1"
|
||||
|
||||
t.Run("should return empty if user is not a member of any board and there are no public boards on the team", func(t *testing.T) {
|
||||
boards, err := store.SearchBoardsForUser("", userID)
|
||||
boards, err := store.SearchBoardsForUser("", userID, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, boards)
|
||||
})
|
||||
@ -743,6 +752,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID string
|
||||
UserID string
|
||||
Term string
|
||||
IncludePublic bool
|
||||
ExpectedBoardIDs []string
|
||||
}{
|
||||
{
|
||||
@ -750,6 +760,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "",
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
|
||||
},
|
||||
{
|
||||
@ -757,13 +768,23 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "board",
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
|
||||
},
|
||||
{
|
||||
Name: "should find all with term board where the user is member of",
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "board",
|
||||
IncludePublic: false,
|
||||
ExpectedBoardIDs: []string{board1.ID, board3.ID, board5.ID},
|
||||
},
|
||||
{
|
||||
Name: "should find only public as per the term, wether user is a member or not",
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "public",
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID, board5.ID},
|
||||
},
|
||||
{
|
||||
@ -771,6 +792,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "priv",
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board3.ID},
|
||||
},
|
||||
{
|
||||
@ -778,13 +800,14 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID2,
|
||||
UserID: userID,
|
||||
Term: "non-matching-term",
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
boards, err := store.SearchBoardsForUser(tc.Term, tc.UserID)
|
||||
boards, err := store.SearchBoardsForUser(tc.Term, tc.UserID, tc.IncludePublic)
|
||||
require.NoError(t, err)
|
||||
|
||||
boardIDs := []string{}
|
||||
|
@ -41,7 +41,7 @@ func testCreateBoardsAndBlocks(t *testing.T, store store.Store) {
|
||||
teamID := testTeamID
|
||||
userID := testUserID
|
||||
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID)
|
||||
boards, err := store.GetBoardsForUserAndTeam(userID, teamID, true)
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, boards)
|
||||
|
||||
|
@ -47,7 +47,7 @@ func StoreTestUserStore(t *testing.T, setup func(t *testing.T) (store.Store, fun
|
||||
|
||||
func testGetTeamUsers(t *testing.T, store store.Store) {
|
||||
t.Run("GetTeamUSers", func(t *testing.T) {
|
||||
users, err := store.GetUsersByTeam("team_1")
|
||||
users, err := store.GetUsersByTeam("team_1", "")
|
||||
require.Equal(t, 0, len(users))
|
||||
require.True(t, model.IsErrNotFound(err), "Should be ErrNotFound compatible error")
|
||||
|
||||
@ -66,7 +66,7 @@ func testGetTeamUsers(t *testing.T, store store.Store) {
|
||||
})
|
||||
}()
|
||||
|
||||
users, err = store.GetUsersByTeam("team_1")
|
||||
users, err = store.GetUsersByTeam("team_1", "")
|
||||
require.Equal(t, 1, len(users))
|
||||
require.Equal(t, "darth.vader", users[0].Username)
|
||||
require.NoError(t, err)
|
||||
|
@ -1,4 +1,4 @@
|
||||
//go:generate mockgen --build_flags=--mod=mod -destination=mocks/mockstore.go -package mocks . Store
|
||||
//go:generate mockgen -destination=mocks/mockstore.go -package mocks . Store
|
||||
package ws
|
||||
|
||||
import (
|
||||
|
@ -1,4 +1,4 @@
|
||||
//go:generate mockgen --build_flags=--mod=mod -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
|
||||
//go:generate mockgen -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
|
||||
package ws
|
||||
|
||||
import (
|
||||
|
@ -69,6 +69,7 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const template1Title = 'Template 1'
|
||||
@ -84,7 +85,7 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
},
|
||||
users: {
|
||||
me,
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
},
|
||||
boards: {
|
||||
boards: [
|
||||
|
@ -106,6 +106,7 @@ describe('components/boardTemplateSelector/boardTemplateSelectorItem', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ import SearchForBoardsTourStep from '../../components/onboardingTour/searchForBo
|
||||
|
||||
type Props = {
|
||||
onBoardTemplateSelectorOpen?: () => void,
|
||||
userIsGuest?: boolean,
|
||||
}
|
||||
|
||||
const BoardsSwitcher = (props: Props): JSX.Element => {
|
||||
@ -93,7 +94,7 @@ const BoardsSwitcher = (props: Props): JSX.Element => {
|
||||
</div>
|
||||
{shouldViewSearchForBoardsTour && <div><SearchForBoardsTourStep/></div>}
|
||||
{
|
||||
Utils.isFocalboardPlugin() &&
|
||||
Utils.isFocalboardPlugin() && !props.userIsGuest &&
|
||||
<IconButton
|
||||
size='small'
|
||||
inverted={true}
|
||||
|
@ -14,7 +14,9 @@ exports[`components/cardDetail/comment return comment 1`] = `
|
||||
/>
|
||||
<div
|
||||
class="comment-username"
|
||||
/>
|
||||
>
|
||||
username_1
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="October 01, 2020, 12:00 AM"
|
||||
@ -136,7 +138,9 @@ exports[`components/cardDetail/comment return comment and delete comment 1`] = `
|
||||
/>
|
||||
<div
|
||||
class="comment-username"
|
||||
/>
|
||||
>
|
||||
username_1
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="October 01, 2020, 12:00 AM"
|
||||
@ -258,7 +262,323 @@ exports[`components/cardDetail/comment return comment readonly 1`] = `
|
||||
/>
|
||||
<div
|
||||
class="comment-username"
|
||||
/>
|
||||
>
|
||||
username_1
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="October 01, 2020, 12:00 AM"
|
||||
>
|
||||
<div
|
||||
class="comment-date"
|
||||
>
|
||||
a day ago
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="comment-text"
|
||||
>
|
||||
<p>
|
||||
Test comment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/cardDetail/comment return guest comment 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Comment comment"
|
||||
>
|
||||
<div
|
||||
class="comment-header"
|
||||
>
|
||||
<img
|
||||
class="comment-avatar"
|
||||
src="data:image/svg+xml"
|
||||
/>
|
||||
<div
|
||||
class="comment-username"
|
||||
>
|
||||
username_1
|
||||
</div>
|
||||
<div
|
||||
class="GuestBadge"
|
||||
>
|
||||
<div
|
||||
class="GuestBadge__box"
|
||||
>
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="October 01, 2020, 12:00 AM"
|
||||
>
|
||||
<div
|
||||
class="comment-date"
|
||||
>
|
||||
a day ago
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="Menu noselect left "
|
||||
>
|
||||
<div
|
||||
class="menu-contents"
|
||||
>
|
||||
<div
|
||||
class="menu-options"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Delete"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-spacer hideOnWidescreen"
|
||||
/>
|
||||
<div
|
||||
class="menu-options hideOnWidescreen"
|
||||
>
|
||||
<div
|
||||
aria-label="Cancel"
|
||||
class="MenuOption TextOption menu-option menu-cancel"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="comment-text"
|
||||
>
|
||||
<p>
|
||||
Test comment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/cardDetail/comment return guest comment and delete comment 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Comment comment"
|
||||
>
|
||||
<div
|
||||
class="comment-header"
|
||||
>
|
||||
<img
|
||||
class="comment-avatar"
|
||||
src="data:image/svg+xml"
|
||||
/>
|
||||
<div
|
||||
class="comment-username"
|
||||
>
|
||||
username_1
|
||||
</div>
|
||||
<div
|
||||
class="GuestBadge"
|
||||
>
|
||||
<div
|
||||
class="GuestBadge__box"
|
||||
>
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="October 01, 2020, 12:00 AM"
|
||||
>
|
||||
<div
|
||||
class="comment-date"
|
||||
>
|
||||
a day ago
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="Menu noselect left "
|
||||
>
|
||||
<div
|
||||
class="menu-contents"
|
||||
>
|
||||
<div
|
||||
class="menu-options"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Delete"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-spacer hideOnWidescreen"
|
||||
/>
|
||||
<div
|
||||
class="menu-options hideOnWidescreen"
|
||||
>
|
||||
<div
|
||||
aria-label="Cancel"
|
||||
class="MenuOption TextOption menu-option menu-cancel"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="comment-text"
|
||||
>
|
||||
<p>
|
||||
Test comment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/cardDetail/comment return guest comment readonly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Comment comment"
|
||||
>
|
||||
<div
|
||||
class="comment-header"
|
||||
>
|
||||
<img
|
||||
class="comment-avatar"
|
||||
src="data:image/svg+xml"
|
||||
/>
|
||||
<div
|
||||
class="comment-username"
|
||||
>
|
||||
username_1
|
||||
</div>
|
||||
<div
|
||||
class="GuestBadge"
|
||||
>
|
||||
<div
|
||||
class="GuestBadge__box"
|
||||
>
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="October 01, 2020, 12:00 AM"
|
||||
|
@ -68,9 +68,9 @@ describe('components/cardDetail/CardDetail', () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
users: {
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
current: {id: 'team-id'},
|
||||
@ -149,9 +149,9 @@ describe('components/cardDetail/CardDetail', () => {
|
||||
},
|
||||
},
|
||||
users: {
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -211,9 +211,9 @@ describe('components/cardDetail/CardDetail', () => {
|
||||
focalboard_onboardingTourStep: '0',
|
||||
},
|
||||
},
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
current: {id: 'team-id'},
|
||||
@ -317,9 +317,9 @@ describe('components/cardDetail/CardDetail', () => {
|
||||
focalboard_onboardingTourStep: '1',
|
||||
},
|
||||
},
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
current: {id: 'team-id'},
|
||||
@ -421,9 +421,9 @@ describe('components/cardDetail/CardDetail', () => {
|
||||
focalboard_onboardingTourStep: '2',
|
||||
},
|
||||
},
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
current: {id: 'team-id'},
|
||||
|
@ -32,9 +32,7 @@ const userImageUrl = 'data:image/svg+xml'
|
||||
describe('components/cardDetail/comment', () => {
|
||||
const state = {
|
||||
users: {
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {[comment.modifiedBy]: {username: 'username_1'}},
|
||||
},
|
||||
}
|
||||
const store = mockStateStore([], state)
|
||||
@ -101,4 +99,57 @@ describe('components/cardDetail/comment', () => {
|
||||
expect(mockedMutator.deleteBlock).toBeCalledTimes(1)
|
||||
expect(mockedMutator.deleteBlock).toBeCalledWith(comment)
|
||||
})
|
||||
|
||||
test('return guest comment', () => {
|
||||
const localStore = mockStateStore([], {users: {boardUsers: {[comment.modifiedBy]: {username: 'username_1', is_guest: true}}}})
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={localStore}>
|
||||
<Comment
|
||||
comment={comment}
|
||||
userId={comment.modifiedBy}
|
||||
userImageUrl={userImageUrl}
|
||||
readonly={false}
|
||||
/>
|
||||
</ReduxProvider>,
|
||||
))
|
||||
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
|
||||
userEvent.click(buttonElement)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('return guest comment readonly', () => {
|
||||
const localStore = mockStateStore([], {users: {boardUsers: {[comment.modifiedBy]: {username: 'username_1', is_guest: true}}}})
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={localStore}>
|
||||
<Comment
|
||||
comment={comment}
|
||||
userId={comment.modifiedBy}
|
||||
userImageUrl={userImageUrl}
|
||||
readonly={true}
|
||||
/>
|
||||
</ReduxProvider>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('return guest comment and delete comment', () => {
|
||||
const localStore = mockStateStore([], {users: {boardUsers: {[comment.modifiedBy]: {username: 'username_1', is_guest: true}}}})
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={localStore}>
|
||||
<Comment
|
||||
comment={comment}
|
||||
userId={comment.modifiedBy}
|
||||
userImageUrl={userImageUrl}
|
||||
readonly={false}
|
||||
/>
|
||||
</ReduxProvider>,
|
||||
))
|
||||
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
|
||||
userEvent.click(buttonElement)
|
||||
expect(container).toMatchSnapshot()
|
||||
const buttonDelete = screen.getByRole('button', {name: 'Delete'})
|
||||
userEvent.click(buttonDelete)
|
||||
expect(mockedMutator.deleteBlock).toBeCalledTimes(1)
|
||||
expect(mockedMutator.deleteBlock).toBeCalledWith(comment)
|
||||
})
|
||||
})
|
||||
|
@ -14,6 +14,7 @@ import MenuWrapper from '../../widgets/menuWrapper'
|
||||
import {getUser} from '../../store/users'
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
import Tooltip from '../../widgets/tooltip'
|
||||
import GuestBadge from '../../widgets/guestBadge'
|
||||
|
||||
import './comment.scss'
|
||||
|
||||
@ -42,6 +43,8 @@ const Comment: FC<Props> = (props: Props) => {
|
||||
src={userImageUrl}
|
||||
/>
|
||||
<div className='comment-username'>{user?.username}</div>
|
||||
<GuestBadge show={user?.is_guest}/>
|
||||
|
||||
<Tooltip title={Utils.displayDateTime(date, intl)}>
|
||||
<div className='comment-date'>
|
||||
{Utils.relativeDisplayDateTime(date, intl)}
|
||||
|
@ -48,9 +48,9 @@ describe('components/cardDetail/CommentsList', () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
users: {
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
boards: {
|
||||
boards: {
|
||||
@ -111,9 +111,9 @@ describe('components/cardDetail/CommentsList', () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
users: {
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
boards: {
|
||||
boards: {
|
||||
|
@ -85,12 +85,9 @@ describe('components/centerPanel', () => {
|
||||
focalboard_onboardingTourStarted: false,
|
||||
},
|
||||
},
|
||||
workspaceUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
teams: {
|
||||
|
@ -95,6 +95,7 @@ const GlobalHeaderSettingsMenu = (props: Props) => {
|
||||
isOn={randomIcons}
|
||||
onClick={async () => toggleRandomIcons()}
|
||||
/>
|
||||
{me?.is_guest !== true &&
|
||||
<Menu.Text
|
||||
id='product-tour'
|
||||
className='product-tour'
|
||||
@ -128,7 +129,7 @@ const GlobalHeaderSettingsMenu = (props: Props) => {
|
||||
|
||||
props.history.push(newPath)
|
||||
}}
|
||||
/>
|
||||
/>}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
|
28
webapp/src/components/guestNoBoards.scss
Normal file
28
webapp/src/components/guestNoBoards.scss
Normal file
@ -0,0 +1,28 @@
|
||||
.GuestNoBoards {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 52px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 20px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin: 56px 0;
|
||||
}
|
||||
}
|
32
webapp/src/components/guestNoBoards.tsx
Normal file
32
webapp/src/components/guestNoBoards.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import ErrorIllustration from '../svg/error-illustration'
|
||||
|
||||
import './guestNoBoards.scss'
|
||||
|
||||
const GuestNoBoards = () => {
|
||||
return (
|
||||
<div className='GuestNoBoards'>
|
||||
<div>
|
||||
<div className='title'>
|
||||
<FormattedMessage
|
||||
id='guest-no-board.title'
|
||||
defaultMessage={'No boards yet'}
|
||||
/>
|
||||
</div>
|
||||
<div className='subtitle'>
|
||||
<FormattedMessage
|
||||
id='guest-no-board.subtitle'
|
||||
defaultMessage={'You don\'t have access to any board in this team yet, please wait until somebody adds you to any board.'}
|
||||
/>
|
||||
</div>
|
||||
<ErrorIllustration/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(GuestNoBoards)
|
@ -2,6 +2,9 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {ReactElement} from 'react'
|
||||
import {EntryComponentProps} from '@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry'
|
||||
|
||||
import GuestBadge from '../../../widgets/guestBadge'
|
||||
|
||||
import './entryComponent.scss'
|
||||
|
||||
const BotBadge = (window as any).Components?.BotBadge
|
||||
@ -26,6 +29,7 @@ const Entry = (props: EntryComponentProps): ReactElement => {
|
||||
<div className={theme?.mentionSuggestionsEntryText}>
|
||||
{mention.name}
|
||||
{BotBadge && <BotBadge show={mention.is_bot}/>}
|
||||
<GuestBadge show={mention.is_guest}/>
|
||||
</div>
|
||||
<div className={theme?.mentionSuggestionsEntryText}>
|
||||
{mention.displayName}
|
||||
|
@ -72,6 +72,7 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
|
||||
name: user.username,
|
||||
avatar: `${imageURLForUser ? imageURLForUser(user.id) : ''}`,
|
||||
is_bot: user.is_bot,
|
||||
is_guest: user.is_guest,
|
||||
displayName: Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay)}
|
||||
))
|
||||
setSuggestions(mentions)
|
||||
|
@ -48,6 +48,7 @@ describe('components/messages/CloudMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
@ -82,6 +83,7 @@ describe('components/messages/CloudMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
@ -114,6 +116,7 @@ describe('components/messages/CloudMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
@ -154,6 +157,7 @@ describe('components/messages/CloudMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: Date.now() - (1000 * 60 * 60 * 24), //24 hours,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
|
@ -43,6 +43,7 @@ describe('components/messages/VersionMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
@ -76,6 +77,7 @@ describe('components/messages/VersionMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
@ -107,6 +109,7 @@ describe('components/messages/VersionMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
@ -160,6 +163,7 @@ describe('components/messages/VersionMessage', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
const state = {
|
||||
|
@ -250,4 +250,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ShareBoard-user-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
border-radius: 50px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -114,6 +114,7 @@ const me: IUser = {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
@ -131,7 +132,7 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
},
|
||||
users: {
|
||||
me,
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
boards: {
|
||||
|
@ -31,6 +31,7 @@ import Switch from '../../widgets/switch'
|
||||
import Button from '../../widgets/buttons/button'
|
||||
import {sendFlashMessage} from '../flashMessages'
|
||||
import {Permission} from '../../constants'
|
||||
import GuestBadge from '../../widgets/guestBadge'
|
||||
|
||||
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
|
||||
|
||||
@ -299,6 +300,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
<div className='ml-3'>
|
||||
<strong>{Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay)}</strong>
|
||||
<strong className='ml-2 text-light'>{`@${user.username}`}</strong>
|
||||
<GuestBadge show={Boolean(user?.is_guest)}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -358,6 +360,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
value={selectedUser}
|
||||
className={'userSearchInput'}
|
||||
cacheOptions={true}
|
||||
filterOption={(o) => !members[o.value]}
|
||||
loadOptions={async (inputValue: string) => {
|
||||
const result = []
|
||||
if (Utils.isFocalboardPlugin()) {
|
||||
|
@ -41,6 +41,7 @@ describe('src/components/shareBoard/teamPermissionsRow', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
|
@ -43,6 +43,7 @@ describe('src/components/shareBoard/userPermissionsRow', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,7 @@ import {BoardMember} from '../../blocks/board'
|
||||
import {IUser} from '../../user'
|
||||
import {Utils} from '../../utils'
|
||||
import {Permission} from '../../constants'
|
||||
import GuestBadge from '../../widgets/guestBadge'
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
import {getCurrentBoard} from '../../store/boards'
|
||||
|
||||
@ -54,6 +55,7 @@ const UserPermissionsRow = (props: Props): JSX.Element => {
|
||||
<strong>{Utils.getUserDisplayName(user, teammateNameDisplay)}</strong>
|
||||
<strong className='ml-2 text-light'>{`@${user.username}`}</strong>
|
||||
{isMe && <strong className='ml-2 text-light'>{intl.formatMessage({id: 'ShareBoard.userPermissionsYouText', defaultMessage: '(You)'})}</strong>}
|
||||
<GuestBadge show={user.is_guest}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -89,13 +91,14 @@ const UserPermissionsRow = (props: Props): JSX.Element => {
|
||||
name={intl.formatMessage({id: 'BoardMember.schemeEditor', defaultMessage: 'Editor'})}
|
||||
onClick={() => props.onUpdateBoardMember(member, 'Editor')}
|
||||
/>
|
||||
{user.is_guest !== true &&
|
||||
<Menu.Text
|
||||
id='Admin'
|
||||
check={true}
|
||||
icon={currentRole === 'Admin' ? <CheckIcon/> : null}
|
||||
name={intl.formatMessage({id: 'BoardMember.schemeAdmin', defaultMessage: 'Admin'})}
|
||||
onClick={() => props.onUpdateBoardMember(member, 'Admin')}
|
||||
/>
|
||||
/>}
|
||||
<Menu.Separator/>
|
||||
<Menu.Text
|
||||
id='Remove'
|
||||
|
@ -86,7 +86,7 @@ exports[`components/sidebarBoardItem sidebar board item 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper x"
|
||||
class="MenuWrapper menuOpen"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
@ -97,6 +97,373 @@ exports[`components/sidebarBoardItem sidebar board item 1`] = `
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="Menu noselect auto fixed"
|
||||
style="top: 40px;"
|
||||
>
|
||||
<div
|
||||
class="menu-contents"
|
||||
>
|
||||
<div
|
||||
class="menu-options"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Delete board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Delete board
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="MenuOption SubMenuOption menu-option boardMoveToCategorySubmenu"
|
||||
id="moveBlock"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-folder-plus-outline CreateNewFolderIcon"
|
||||
/>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Move To...
|
||||
</div>
|
||||
<svg
|
||||
class="SubmenuTriangleIcon Icon"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="50,35 75,50 50,65"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Duplicate board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-content-copy content-copy"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Duplicate board
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="New template from board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-plus AddIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
New template from board
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Hide board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Hide board
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-spacer hideOnWidescreen"
|
||||
/>
|
||||
<div
|
||||
class="menu-options hideOnWidescreen"
|
||||
>
|
||||
<div
|
||||
aria-label="Cancel"
|
||||
class="MenuOption TextOption menu-option menu-cancel"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="SidebarBoardItem sidebar-view-item active"
|
||||
>
|
||||
<svg
|
||||
class="BoardIcon Icon"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
opacity="0.8"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M4 4H20V20H4V4ZM2 4C2 2.89543 2.89543 2 4 2H20C21.1046 2 22 2.89543 22 4V20C22 21.1046 21.1046 22 20 22H4C2.89543 22 2 21.1046 2 20V4ZM8 6H6V12H8V6ZM11 6H13V16H11V6ZM18 6H16V9H18V6Z"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
class="octo-sidebar-title"
|
||||
title="view title"
|
||||
>
|
||||
view title
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/sidebarBoardItem sidebar board item for guest 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="SidebarBoardItem subitem active"
|
||||
>
|
||||
<div
|
||||
class="octo-sidebar-icon"
|
||||
>
|
||||
i
|
||||
</div>
|
||||
<div
|
||||
class="octo-sidebar-title"
|
||||
title="board title"
|
||||
>
|
||||
board title
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper menuOpen"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="Menu noselect auto fixed"
|
||||
style="top: 40px;"
|
||||
>
|
||||
<div
|
||||
class="menu-contents"
|
||||
>
|
||||
<div
|
||||
class="menu-options"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Delete board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Delete board
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="MenuOption SubMenuOption menu-option boardMoveToCategorySubmenu"
|
||||
id="moveBlock"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-folder-plus-outline CreateNewFolderIcon"
|
||||
/>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Move To...
|
||||
</div>
|
||||
<svg
|
||||
class="SubmenuTriangleIcon Icon"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="50,35 75,50 50,65"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div />
|
||||
<div>
|
||||
<div
|
||||
aria-label="Hide board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Hide board
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-spacer hideOnWidescreen"
|
||||
/>
|
||||
<div
|
||||
class="menu-options hideOnWidescreen"
|
||||
>
|
||||
<div
|
||||
aria-label="Cancel"
|
||||
class="MenuOption TextOption menu-option menu-cancel"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-option__content"
|
||||
>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,6 +11,7 @@ import ShowSidebarIcon from '../../widgets/icons/showSidebar'
|
||||
import {getMySortedBoards} from '../../store/boards'
|
||||
import {useAppDispatch, useAppSelector} from '../../store/hooks'
|
||||
import {Utils} from '../../utils'
|
||||
import {IUser} from "../../user"
|
||||
|
||||
import './sidebar.scss'
|
||||
|
||||
@ -28,11 +29,10 @@ import BoardsSwitcher from '../boardsSwitcher/boardsSwitcher'
|
||||
import wsClient, {WSClient} from '../../wsclient'
|
||||
|
||||
import {getCurrentTeam} from '../../store/teams'
|
||||
import {getMe} from '../../store/users'
|
||||
|
||||
import {Constants} from "../../constants"
|
||||
|
||||
import {getMe} from "../../store/users"
|
||||
|
||||
import SidebarCategory from './sidebarCategory'
|
||||
import SidebarSettingsMenu from './sidebarSettingsMenu'
|
||||
import SidebarUserMenu from './sidebarUserMenu'
|
||||
@ -58,8 +58,8 @@ const Sidebar = (props: Props) => {
|
||||
const boards = useAppSelector(getMySortedBoards)
|
||||
const dispatch = useAppDispatch()
|
||||
const partialCategories = useAppSelector<Array<CategoryBoards>>(getSidebarCategories)
|
||||
const me = useAppSelector<IUser|null>(getMe)
|
||||
const sidebarCategories = addMissingItems(partialCategories, boards)
|
||||
const me = useAppSelector(getMe)
|
||||
|
||||
useEffect(() => {
|
||||
wsClient.addOnChange((_: WSClient, categories: Category[]) => {
|
||||
@ -180,7 +180,10 @@ const Sidebar = (props: Props) => {
|
||||
</div>
|
||||
}
|
||||
|
||||
<BoardsSwitcher onBoardTemplateSelectorOpen={props.onBoardTemplateSelectorOpen}/>
|
||||
<BoardsSwitcher
|
||||
onBoardTemplateSelectorOpen={props.onBoardTemplateSelectorOpen}
|
||||
userIsGuest={me?.is_guest}
|
||||
/>
|
||||
|
||||
<div className='octo-sidebar-list'>
|
||||
{
|
||||
|
@ -6,6 +6,7 @@ import {createMemoryHistory} from 'history'
|
||||
import {Router} from 'react-router-dom'
|
||||
|
||||
import {render} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
|
||||
@ -51,6 +52,9 @@ describe('components/sidebarBoardItem', () => {
|
||||
boards: {
|
||||
[board.id]: board,
|
||||
},
|
||||
myBoardMemberships: {
|
||||
[board.id]: {userId: 'user_id_1', schemeAdmin: true},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
current: view.id,
|
||||
@ -85,6 +89,9 @@ describe('components/sidebarBoardItem', () => {
|
||||
</ReduxProvider>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
const elementMenuWrapper = container.querySelector('.SidebarBoardItem div.MenuWrapper')
|
||||
expect(elementMenuWrapper).not.toBeNull()
|
||||
userEvent.click(elementMenuWrapper!)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
@ -111,4 +118,30 @@ describe('components/sidebarBoardItem', () => {
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('sidebar board item for guest', () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({...state, users: { me: { is_guest: true }}})
|
||||
|
||||
const component = wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<Router history={history}>
|
||||
<SidebarBoardItem
|
||||
categoryBoards={categoryBoards1}
|
||||
board={board}
|
||||
allCategories={allCategoryBoards}
|
||||
isActive={true}
|
||||
showBoard={jest.fn()}
|
||||
showView={jest.fn()}
|
||||
onDeleteRequest={jest.fn()}
|
||||
/>
|
||||
</Router>
|
||||
</ReduxProvider>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
const elementMenuWrapper = container.querySelector('.SidebarBoardItem div.MenuWrapper')
|
||||
expect(elementMenuWrapper).not.toBeNull()
|
||||
userEvent.click(elementMenuWrapper!)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
@ -259,18 +259,20 @@ const SidebarBoardItem = (props: Props) => {
|
||||
>
|
||||
{generateMoveToCategoryOptions(board.id)}
|
||||
</Menu.SubMenu>
|
||||
{!me?.is_guest &&
|
||||
<Menu.Text
|
||||
id='duplicateBoard'
|
||||
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate board'})}
|
||||
icon={<DuplicateIcon/>}
|
||||
onClick={() => handleDuplicateBoard(board.isTemplate)}
|
||||
/>
|
||||
/>}
|
||||
{!me?.is_guest &&
|
||||
<Menu.Text
|
||||
id='templateFromBoard'
|
||||
name={intl.formatMessage({id: 'Sidebar.template-from-board', defaultMessage: 'New template from board'})}
|
||||
icon={<AddIcon/>}
|
||||
onClick={() => handleDuplicateBoard(true)}
|
||||
/>
|
||||
/>}
|
||||
<Menu.Text
|
||||
id='hideBoard'
|
||||
name={intl.formatMessage({id: 'HideBoard.MenuOption', defaultMessage: 'Hide board'})}
|
||||
|
@ -65,9 +65,9 @@ describe('components/viewHeader/viewHeaderGroupByMenu', () => {
|
||||
id: 'user-id-1',
|
||||
username: 'username_1',
|
||||
},
|
||||
boardUsers: [
|
||||
{username: 'username_1'},
|
||||
],
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1'},
|
||||
},
|
||||
},
|
||||
boards: {
|
||||
current: board.id,
|
||||
|
@ -83,6 +83,7 @@ const me: IUser = {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
@ -113,7 +114,7 @@ describe('src/components/workspace', () => {
|
||||
},
|
||||
users: {
|
||||
me,
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
boards: {
|
||||
@ -246,7 +247,7 @@ describe('src/components/workspace', () => {
|
||||
const emptyStore = mockStateStore([], {
|
||||
users: {
|
||||
me,
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
},
|
||||
teams: {
|
||||
current: {id: 'team-id', title: 'Test Team'},
|
||||
@ -329,7 +330,7 @@ describe('src/components/workspace', () => {
|
||||
is_bot: false,
|
||||
roles: 'system_user',
|
||||
},
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
boards: {
|
||||
@ -433,7 +434,7 @@ describe('src/components/workspace', () => {
|
||||
is_bot: false,
|
||||
roles: 'system_user',
|
||||
},
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
boards: {
|
||||
@ -542,7 +543,7 @@ describe('src/components/workspace', () => {
|
||||
is_bot: false,
|
||||
roles: 'system_user',
|
||||
},
|
||||
boardUsers: [me],
|
||||
boardUsers: {[me.id]: me},
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
boards: {
|
||||
|
@ -20,12 +20,14 @@ import {getClientConfig, setClientConfig} from '../store/clientConfig'
|
||||
import wsClient, {WSClient} from '../wsclient'
|
||||
import {ClientConfig} from '../config/clientConfig'
|
||||
import {Utils} from '../utils'
|
||||
import {IUser} from '../user'
|
||||
import propsRegistry from '../properties'
|
||||
|
||||
import {getMe} from "../store/users"
|
||||
|
||||
import CenterPanel from './centerPanel'
|
||||
import BoardTemplateSelector from './boardTemplateSelector/boardTemplateSelector'
|
||||
import GuestNoBoards from './guestNoBoards'
|
||||
|
||||
import Sidebar from './sidebar/sidebar'
|
||||
|
||||
@ -50,7 +52,7 @@ function CenterContent(props: Props) {
|
||||
const cardLimitTimestamp = useAppSelector(getCardLimitTimestamp)
|
||||
const history = useHistory()
|
||||
const dispatch = useAppDispatch()
|
||||
const me = useAppSelector(getMe)
|
||||
const me = useAppSelector<IUser|null>(getMe)
|
||||
|
||||
const isBoardHidden = () => {
|
||||
const hiddenBoardIDs = me?.props.hiddenBoardIDs || {}
|
||||
@ -105,6 +107,9 @@ function CenterContent(props: Props) {
|
||||
)
|
||||
|
||||
if (match.params.channelId) {
|
||||
if (me?.is_guest) {
|
||||
return <GuestNoBoards/>
|
||||
}
|
||||
return templateSelector
|
||||
}
|
||||
|
||||
@ -140,6 +145,10 @@ function CenterContent(props: Props) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (me?.is_guest) {
|
||||
return <GuestNoBoards/>
|
||||
}
|
||||
|
||||
return templateSelector
|
||||
}
|
||||
|
||||
|
@ -93,6 +93,18 @@ const WelcomePage = () => {
|
||||
history.replace(newPath)
|
||||
}
|
||||
|
||||
// It's still possible for a guest to end up at this route/page directly, so
|
||||
// let's mark it as viewed, if necessary, and route them forward
|
||||
if (me?.is_guest) {
|
||||
if (!me?.props[UserPropPrefix + UserSettingKey.WelcomePageViewed]) {
|
||||
(async() => {
|
||||
await setWelcomePageViewed(me.id)
|
||||
})()
|
||||
}
|
||||
goForward()
|
||||
return null
|
||||
}
|
||||
|
||||
if (me?.props && me?.props[UserPropPrefix + UserSettingKey.WelcomePageViewed]) {
|
||||
goForward()
|
||||
return null
|
||||
@ -128,6 +140,7 @@ const WelcomePage = () => {
|
||||
alt='Boards Welcome Image'
|
||||
/>
|
||||
|
||||
{me?.is_guest !== true &&
|
||||
<Button
|
||||
onClick={startTour}
|
||||
filled={true}
|
||||
@ -143,8 +156,9 @@ const WelcomePage = () => {
|
||||
id='WelcomePage.Explore.Button'
|
||||
defaultMessage='Take a tour'
|
||||
/>
|
||||
</Button>
|
||||
</Button>}
|
||||
|
||||
{me?.is_guest !== true &&
|
||||
<a
|
||||
className='skip'
|
||||
onClick={skipTour}
|
||||
@ -153,7 +167,18 @@ const WelcomePage = () => {
|
||||
id='WelcomePage.NoThanks.Text'
|
||||
defaultMessage="No thanks, I'll figure it out myself"
|
||||
/>
|
||||
</a>
|
||||
</a>}
|
||||
{me?.is_guest === true &&
|
||||
<Button
|
||||
onClick={skipTour}
|
||||
filled={true}
|
||||
size='large'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='WelcomePage.StartUsingIt.Text'
|
||||
defaultMessage="Start using it"
|
||||
/>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -13,3 +13,26 @@ exports[`properties/createdBy should match snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`properties/createdBy should match snapshot as guest 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Person octo-propertyvalue octo-propertyvalue--readonly"
|
||||
>
|
||||
<div
|
||||
class="Person-item"
|
||||
>
|
||||
username_1
|
||||
<div
|
||||
class="GuestBadge"
|
||||
>
|
||||
<div
|
||||
class="GuestBadge__box"
|
||||
>
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
@ -8,9 +8,11 @@ import {render} from '@testing-library/react'
|
||||
import configureStore from 'redux-mock-store'
|
||||
|
||||
import {IUser} from '../../user'
|
||||
import {createCard, Card} from '../../blocks/card'
|
||||
import {createCard} from '../../blocks/card'
|
||||
import {Board, IPropertyTemplate} from '../../blocks/board'
|
||||
|
||||
import {wrapIntl} from '../../testUtils'
|
||||
|
||||
import CreatedByProperty from './property'
|
||||
import CreatedBy from './createdBy'
|
||||
|
||||
@ -33,12 +35,48 @@ describe('properties/createdBy', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const component = (
|
||||
const component = wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<CreatedBy
|
||||
property={new CreatedByProperty}
|
||||
board={{} as Board}
|
||||
card={{createdBy: 'user-id-1'} as Card}
|
||||
card={card}
|
||||
readOnly={false}
|
||||
propertyTemplate={{} as IPropertyTemplate}
|
||||
propertyValue={''}
|
||||
showEmptyPlaceholder={false}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
)
|
||||
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot as guest', () => {
|
||||
const card = createCard()
|
||||
card.createdBy = 'user-id-1'
|
||||
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
users: {
|
||||
boardUsers: {
|
||||
'user-id-1': {username: 'username_1', is_guest: true} as IUser,
|
||||
},
|
||||
},
|
||||
clientConfig: {
|
||||
value: {
|
||||
teammateNameDisplay: 'username',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const component = wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<CreatedBy
|
||||
property={new CreatedByProperty}
|
||||
board={{} as Board}
|
||||
card={card}
|
||||
readOnly={false}
|
||||
propertyTemplate={{} as IPropertyTemplate}
|
||||
propertyValue={''}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`properties/user not readOnly not existing user 1`] = `
|
||||
exports[`properties/person not readOnly not existing user 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Person octo-propertyvalue css-b62m3t-container"
|
||||
@ -79,7 +79,7 @@ exports[`properties/user not readOnly not existing user 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`properties/user not readonly 1`] = `
|
||||
exports[`properties/person not readonly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Person octo-propertyvalue css-b62m3t-container"
|
||||
@ -177,7 +177,114 @@ exports[`properties/user not readonly 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`properties/user readonly view 1`] = `
|
||||
exports[`properties/person not readonly guest user 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Person octo-propertyvalue css-b62m3t-container"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-4-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="react-select__control css-18140j1-Control"
|
||||
>
|
||||
<div
|
||||
class="react-select__value-container react-select__value-container--has-value css-433wy7-ValueContainer"
|
||||
>
|
||||
<div
|
||||
class="react-select__single-value css-1lixa2z-singleValue"
|
||||
>
|
||||
<div
|
||||
class="Person-item"
|
||||
>
|
||||
username-1
|
||||
<div
|
||||
class="GuestBadge"
|
||||
>
|
||||
<div
|
||||
class="GuestBadge__box"
|
||||
>
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="react-select__input-container css-ox1y69-Input"
|
||||
data-value=""
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="react-select__input"
|
||||
id="react-select-4-input"
|
||||
role="combobox"
|
||||
spellcheck="false"
|
||||
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
|
||||
tabindex="0"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="react-select__indicators css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="react-select__indicator react-select__clear-indicator css-tpaeio-indicatorContainer"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="css-tj5bde-Svg"
|
||||
focusable="false"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
>
|
||||
<path
|
||||
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="react-select__indicator-separator css-43ykx9-indicatorSeparator"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="react-select__indicator react-select__dropdown-indicator css-19sxey8-indicatorContainer"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="css-tj5bde-Svg"
|
||||
focusable="false"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
>
|
||||
<path
|
||||
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`properties/person readonly view 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Person octo-propertyvalue octo-propertyvalue--readonly"
|
||||
@ -191,14 +298,14 @@ exports[`properties/user readonly view 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`properties/user user dropdown open 1`] = `
|
||||
exports[`properties/person user dropdown open 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Person octo-propertyvalue css-b62m3t-container"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-4-live-region"
|
||||
id="react-select-5-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
@ -236,15 +343,15 @@ exports[`properties/user user dropdown open 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-4-listbox"
|
||||
aria-controls="react-select-5-listbox"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-4-listbox"
|
||||
aria-owns="react-select-5-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="react-select__input"
|
||||
id="react-select-4-input"
|
||||
id="react-select-5-input"
|
||||
role="combobox"
|
||||
spellcheck="false"
|
||||
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
|
||||
@ -298,7 +405,7 @@ exports[`properties/user user dropdown open 1`] = `
|
||||
</div>
|
||||
<div
|
||||
class="react-select__menu css-10b6da7-menu"
|
||||
id="react-select-4-listbox"
|
||||
id="react-select-5-listbox"
|
||||
>
|
||||
<div
|
||||
class="react-select__menu-list css-g29tl0-MenuList"
|
||||
@ -306,7 +413,7 @@ exports[`properties/user user dropdown open 1`] = `
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="react-select__option react-select__option--is-focused react-select__option--is-selected css-10e3bcm-option"
|
||||
id="react-select-4-option-0"
|
||||
id="react-select-5-option-0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
|
@ -19,7 +19,7 @@ import {Card} from '../../blocks/card'
|
||||
import PersonProperty from './property'
|
||||
import Person from './person'
|
||||
|
||||
describe('properties/user', () => {
|
||||
describe('properties/person', () => {
|
||||
const mockStore = configureStore([])
|
||||
const state = {
|
||||
users: {
|
||||
@ -94,6 +94,32 @@ describe('properties/user', () => {
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('not readonly guest user', async () => {
|
||||
const store = mockStore({...state, users: {boardUsers: {'user-id-1': {...state.users.boardUsers['user-id-1'], is_guest: true}}}})
|
||||
const component = wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<Person
|
||||
property={new PersonProperty()}
|
||||
propertyValue={'user-id-1'}
|
||||
readOnly={false}
|
||||
showEmptyPlaceholder={false}
|
||||
propertyTemplate={{} as IPropertyTemplate}
|
||||
board={{} as Board}
|
||||
card={{} as Card}
|
||||
/>
|
||||
</ReduxProvider>,
|
||||
)
|
||||
|
||||
const renderResult = render(component)
|
||||
const container = await waitFor(() => {
|
||||
if (!renderResult.container) {
|
||||
return Promise.reject(new Error('container not found'))
|
||||
}
|
||||
return Promise.resolve(renderResult.container)
|
||||
})
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('readonly view', async () => {
|
||||
const store = mockStore(state)
|
||||
const component = wrapIntl(
|
||||
|
@ -13,6 +13,7 @@ import mutator from '../../mutator'
|
||||
import {getSelectBaseStyle} from '../../theme'
|
||||
import {ClientConfig} from '../../config/clientConfig'
|
||||
import {getClientConfig} from '../../store/clientConfig'
|
||||
import GuestBadge from '../../widgets/guestBadge'
|
||||
|
||||
import {PropertyProps} from '../types'
|
||||
|
||||
@ -55,9 +56,14 @@ const selectStyles = {
|
||||
const Person = (props: PropertyProps): JSX.Element => {
|
||||
const {card, board, propertyTemplate, propertyValue, readOnly} = props
|
||||
|
||||
const boardUsersById = useAppSelector<{[key:string]: IUser}>(getBoardUsers)
|
||||
const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate.id])
|
||||
|
||||
const me: IUser = boardUsersById[propertyValue as string]
|
||||
|
||||
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
|
||||
|
||||
const formatOptionLabel = (user: any) => {
|
||||
const formatOptionLabel = (user: IUser) => {
|
||||
let profileImg
|
||||
if (imageURLForUser) {
|
||||
profileImg = imageURLForUser(user.id)
|
||||
@ -72,19 +78,15 @@ const Person = (props: PropertyProps): JSX.Element => {
|
||||
/>
|
||||
)}
|
||||
{Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay)}
|
||||
<GuestBadge show={Boolean(user?.is_guest)}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const boardUsersById = useAppSelector<{[key:string]: IUser}>(getBoardUsers)
|
||||
const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate.id])
|
||||
|
||||
const user = boardUsersById[propertyValue as string]
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className={`Person ${props.property.valueClassName(true)}`}>
|
||||
{user ? formatOptionLabel(user) : propertyValue}
|
||||
{me ? formatOptionLabel(me) : propertyValue}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ function FBRoute(props: RouteProps) {
|
||||
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
|
||||
|
||||
let redirect: React.ReactNode = null
|
||||
const disableTour = clientConfig?.featureFlags?.disableTour || false
|
||||
// No FTUE for guests
|
||||
const disableTour = me?.is_guest || clientConfig?.featureFlags?.disableTour || false
|
||||
|
||||
const showWelcomePage = !disableTour &&
|
||||
Utils.isFocalboardPlugin() &&
|
||||
|
@ -195,6 +195,7 @@ class TestBlockFactory {
|
||||
create_at: Date.now(),
|
||||
update_at: Date.now(),
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ interface IUser {
|
||||
create_at: number,
|
||||
update_at: number,
|
||||
is_bot: boolean,
|
||||
is_guest: boolean,
|
||||
roles: string,
|
||||
}
|
||||
|
||||
|
@ -201,6 +201,7 @@ describe('utils', () => {
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
is_guest: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
|
15
webapp/src/widgets/__snapshots__/guestBadge.test.tsx.snap
Normal file
15
webapp/src/widgets/__snapshots__/guestBadge.test.tsx.snap
Normal file
@ -0,0 +1,15 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`widgets/guestBadge should match the snapshot on show 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="GuestBadge"
|
||||
>
|
||||
<div
|
||||
class="GuestBadge__box"
|
||||
>
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
16
webapp/src/widgets/guestBadge.scss
Normal file
16
webapp/src/widgets/guestBadge.scss
Normal file
@ -0,0 +1,16 @@
|
||||
.GuestBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.GuestBadge__box {
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
background: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
}
|
22
webapp/src/widgets/guestBadge.test.tsx
Normal file
22
webapp/src/widgets/guestBadge.test.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {render} from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import {wrapIntl} from '../testUtils'
|
||||
|
||||
import GuestBadge from './guestBadge'
|
||||
|
||||
describe('widgets/guestBadge', () => {
|
||||
test('should match the snapshot on show', () => {
|
||||
const {container} = render(wrapIntl(<GuestBadge show={true}/>))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match the snapshot on hide', () => {
|
||||
const {container} = render(wrapIntl(<GuestBadge show={false}/>))
|
||||
expect(container).toMatchInlineSnapshot('<div />')
|
||||
})
|
||||
})
|
29
webapp/src/widgets/guestBadge.tsx
Normal file
29
webapp/src/widgets/guestBadge.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import './guestBadge.scss'
|
||||
|
||||
type Props = {
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
const GuestBadge = (props: Props) => {
|
||||
if (!props.show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className='GuestBadge'>
|
||||
<div className='GuestBadge__box'>
|
||||
<FormattedMessage
|
||||
id='badge.guest'
|
||||
defaultMessage='Guest'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(GuestBadge)
|
Loading…
Reference in New Issue
Block a user