1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-03-20 20:45:00 +02:00

Adding the permissions for commenter + viewer roles (#2882)

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Paul Esch-Laurent <paul.esch-laurent@mattermost.com>
This commit is contained in:
Jesús Espino 2022-08-24 22:36:28 +02:00 committed by GitHub
parent 03a6a963eb
commit 2b39745f68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1112 additions and 51 deletions

View File

@ -221,13 +221,6 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
val := r.URL.Query().Get("disable_notify")
disableNotify := val == True
// in phase 1 we use "manage_board_cards", but we would have to
// check on specific actions for phase 2
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to make board changes"})
return
}
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
@ -242,6 +235,8 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
return
}
hasComments := false
hasContents := false
for _, block := range blocks {
// Error checking
if len(block.Type) < 1 {
@ -250,6 +245,12 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
return
}
if block.Type == model.TypeComment {
hasComments = true
} else {
hasContents = true
}
if block.CreateAt < 1 {
message := fmt.Sprintf("invalid createAt for block id %s", block.ID)
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, message, nil)
@ -269,6 +270,19 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
}
}
if hasContents {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to make board changes"})
return
}
}
if hasComments {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionCommentBoardCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to post card comments"})
return
}
}
blocks = model.GenerateBlockIDs(blocks, a.logger)
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
@ -748,9 +762,16 @@ func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) {
return
}
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modify board members"})
return
if block.Type == model.TypeComment {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionCommentBoardCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to comment on board cards"})
return
}
} else {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modify board cards"})
return
}
}
auditRec := a.makeAuditRecord(r, "duplicateBlock", audit.Fail)

View File

@ -1075,6 +1075,74 @@ func TestPermissionsCreateBoardBlocks(t *testing.T) {
})
}
func TestPermissionsCreateBoardComments(t *testing.T) {
ttCasesF := func(testData TestData) []TestCase {
counter := 0
newBlockJSON := func(boardID string) string {
counter++
return toJSON(t, []*model.Block{{
ID: fmt.Sprintf("%d", counter),
Title: "Comment to create",
BoardID: boardID,
Type: model.TypeComment,
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
}})
}
return []TestCase{
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userCommenter, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.privateBoard.ID), userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userCommenter, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks", methodPost, newBlockJSON(testData.publicBoard.ID), userAdmin, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userCommenter, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.privateTemplate.ID), userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userCommenter, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks", methodPost, newBlockJSON(testData.publicTemplate.ID), userAdmin, http.StatusOK, 1},
}
}
t.Run("plugin", func(t *testing.T) {
th := SetupTestHelperPluginMode(t)
defer th.TearDown()
clients := setupClients(th)
testData := setupData(t, th)
ttCases := ttCasesF(testData)
runTestCases(t, ttCases, testData, clients)
})
t.Run("local", func(t *testing.T) {
th := SetupTestHelperLocalMode(t)
defer th.TearDown()
clients := setupLocalClients(th)
testData := setupData(t, th)
ttCases := ttCasesF(testData)
runTestCases(t, ttCases, testData, clients)
})
}
func TestPermissionsPatchBoardBlocks(t *testing.T) {
newBlocksPatchJSON := func(blockID string) string {
newTitle := "New Patch Block Title"
@ -1420,37 +1488,104 @@ func TestPermissionsDuplicateBoardBlock(t *testing.T) {
}
ttCases := []TestCase{
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-4/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-3/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-2/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-1/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userCommenter, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
// Invalid boardID/blockID combination
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-3/duplicate", methodPost, "", userAdmin, http.StatusNotFound, 0},
}
t.Run("plugin", func(t *testing.T) {
th := SetupTestHelperPluginMode(t)
defer th.TearDown()
clients := setupClients(th)
testData := setupData(t, th)
extraSetup(t, th, testData)
runTestCases(t, ttCases, testData, clients)
})
t.Run("local", func(t *testing.T) {
th := SetupTestHelperLocalMode(t)
defer th.TearDown()
clients := setupLocalClients(th)
testData := setupData(t, th)
extraSetup(t, th, testData)
runTestCases(t, ttCases, testData, clients)
})
}
func TestPermissionsDuplicateBoardComment(t *testing.T) {
extraSetup := func(t *testing.T, th *TestHelper, testData TestData) {
err := th.Server.App().InsertBlock(model.Block{ID: "block-5", Title: "Test", Type: model.TypeComment, BoardID: testData.publicTemplate.ID}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(model.Block{ID: "block-6", Title: "Test", Type: model.TypeComment, BoardID: testData.privateTemplate.ID}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(model.Block{ID: "block-7", Title: "Test", Type: model.TypeComment, BoardID: testData.publicBoard.ID}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(model.Block{ID: "block-8", Title: "Test", Type: model.TypeComment, BoardID: testData.privateBoard.ID}, userAdmin)
require.NoError(t, err)
}
ttCases := []TestCase{
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userCommenter, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_BOARD_ID}/blocks/block-8/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userCommenter, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_BOARD_ID}/blocks/block-7/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userCommenter, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PRIVATE_TEMPLATE_ID}/blocks/block-6/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userAnon, http.StatusUnauthorized, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userTeamMember, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userViewer, http.StatusForbidden, 0},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userCommenter, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userEditor, http.StatusOK, 1},
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-5/duplicate", methodPost, "", userAdmin, http.StatusOK, 1},
// Invalid boardID/blockID combination
{"/boards/{PUBLIC_TEMPLATE_ID}/blocks/block-3/duplicate", methodPost, "", userAdmin, http.StatusNotFound, 0},

View File

@ -17,4 +17,6 @@ var (
PermissionShareBoard = &mmModel.Permission{Id: "share_board", Name: "", Description: "", Scope: ""}
PermissionManageBoardCards = &mmModel.Permission{Id: "manage_board_cards", Name: "", Description: "", Scope: ""}
PermissionManageBoardProperties = &mmModel.Permission{Id: "manage_board_properties", Name: "", Description: "", Scope: ""}
PermissionCommentBoardCards = &mmModel.Permission{Id: "comment_board_cards", Name: "", Description: "", Scope: ""}
PermissionDeleteOthersComments = &mmModel.Permission{Id: "delete_others_comments", Name: "", Description: "", Scope: ""}
)

View File

@ -67,10 +67,12 @@ func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmMod
}
switch permission {
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard:
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments:
return member.SchemeAdmin
case model.PermissionManageBoardCards, model.PermissionManageBoardProperties:
return member.SchemeAdmin || member.SchemeEditor
case model.PermissionCommentBoardCards:
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter
case model.PermissionViewBoard:
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter || member.SchemeViewer
default:

View File

@ -99,10 +99,12 @@ func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmMod
}
switch permission {
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard:
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments:
return member.SchemeAdmin
case model.PermissionManageBoardCards, model.PermissionManageBoardProperties:
return member.SchemeAdmin || member.SchemeEditor
case model.PermissionCommentBoardCards:
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter
case model.PermissionViewBoard:
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter || member.SchemeViewer
default:

View File

@ -61,6 +61,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
}
}, [card.title, title])
const canEditBoardCards = useHasCurrentBoardPermissions([Permission.ManageBoardCards])
const canCommentBoardCards = useHasCurrentBoardPermissions([Permission.CommentBoardCards])
const saveTitleRef = useRef<() => void>(saveTitle)
saveTitleRef.current = saveTitle
@ -207,7 +208,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
comments={comments}
boardId={card.boardId}
cardId={card.id}
readonly={props.readonly || !canEditBoardCards}
readonly={props.readonly || !canCommentBoardCards}
/>
</Fragment>}
</div>

View File

@ -57,6 +57,9 @@ describe('components/cardDetail/CommentsList', () => {
board_id_1: {title: 'Board'},
},
current: 'board_id_1',
myBoardMemberships: {
['board_id_1']: {userId: 'user_id_1', schemeAdmin: true},
},
},
cards: {
cards: {
@ -69,6 +72,9 @@ describe('components/cardDetail/CommentsList', () => {
featureFlags: {},
},
},
teams: {
current: {id: 'team_id_1'},
},
})
const component = (
@ -109,6 +115,18 @@ describe('components/cardDetail/CommentsList', () => {
{username: 'username_1'},
],
},
boards: {
boards: {
board_id_1: {title: 'Board'},
},
current: 'board_id_1',
myBoardMemberships: {
['board_id_1']: {userId: 'user_id_1', schemeAdmin: true},
},
},
teams: {
current: {id: 'team_id_1'}
}
})
const component = (

View File

@ -13,6 +13,8 @@ import {MarkdownEditor} from '../markdownEditor'
import {IUser} from '../../user'
import {getMe} from '../../store/users'
import {useHasCurrentBoardPermissions} from '../../hooks/permissions'
import {Permission} from '../../constants'
import AddCommentTourStep from '../onboardingTour/addComments/addComments'
@ -30,6 +32,7 @@ type Props = {
const CommentsList = (props: Props) => {
const [newComment, setNewComment] = useState('')
const me = useAppSelector<IUser|null>(getMe)
const canDeleteOthersComments = useHasCurrentBoardPermissions([Permission.DeleteOthersComments])
const onSendClicked = () => {
const commentText = newComment
@ -88,15 +91,20 @@ const CommentsList = (props: Props) => {
{/* New comment */}
{!props.readonly && newCommentComponent}
{comments.slice(0).reverse().map((comment) => (
<Comment
key={comment.id}
comment={comment}
userImageUrl={Utils.getProfilePicture(comment.modifiedBy)}
userId={comment.modifiedBy}
readonly={props.readonly}
/>
))}
{comments.slice(0).reverse().map((comment) => {
// Only modify _own_ comments, EXCEPT for Admins, which can delete _any_ comment
// NOTE: editing comments will exist in the future (in addition to deleting)
const canDeleteComment: boolean = canDeleteOthersComments || me?.id === comment.modifiedBy
return (
<Comment
key={comment.id}
comment={comment}
userImageUrl={Utils.getProfilePicture(comment.modifiedBy)}
userId={comment.modifiedBy}
readonly={props.readonly || !canDeleteComment}
/>
)})}
{/* horizontal divider below comments */}
{!(comments.length === 0 && props.readonly) && <hr className='CommentsList__divider'/>}

View File

@ -0,0 +1,683 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/components/shareBoard/userPermissionsRow should match snapshot 1`] = `
<div>
<div
class="user-item"
>
<div
class="user-item__content"
>
<div
class="ml-3"
>
<strong />
<strong
class="ml-2 text-light"
>
@username_1
</strong>
<strong
class="ml-2 text-light"
>
(You)
</strong>
</div>
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="user-item__button"
>
Admin
<i
class="CompassIcon icon-chevron-down CompassIcon"
/>
</button>
<div
class="Menu noselect left "
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div>
<div
aria-label="Viewer"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Viewer
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Commenter"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Commenter
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Editor"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Editor
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Admin"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<svg
class="CheckIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="20,60 40,80 80,40"
/>
</svg>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Admin
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div>
<div
aria-label="Remove member"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Remove member
</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>
`;
exports[`src/components/shareBoard/userPermissionsRow should match snapshot in plugin mode 1`] = `
<div>
<div
class="user-item"
>
<div
class="user-item__content"
>
<img
class="user-item__img"
/>
<div
class="ml-3"
>
<strong />
<strong
class="ml-2 text-light"
>
@username_1
</strong>
<strong
class="ml-2 text-light"
>
(You)
</strong>
</div>
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="user-item__button"
>
Admin
<i
class="CompassIcon icon-chevron-down CompassIcon"
/>
</button>
<div
class="Menu noselect left "
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div>
<div
aria-label="Viewer"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Viewer
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Commenter"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Commenter
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Editor"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Editor
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Admin"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<svg
class="CheckIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="20,60 40,80 80,40"
/>
</svg>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Admin
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div>
<div
aria-label="Remove member"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Remove member
</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>
`;
exports[`src/components/shareBoard/userPermissionsRow should match snapshot in template 1`] = `
<div>
<div
class="user-item"
>
<div
class="user-item__content"
>
<img
class="user-item__img"
/>
<div
class="ml-3"
>
<strong />
<strong
class="ml-2 text-light"
>
@username_1
</strong>
<strong
class="ml-2 text-light"
>
(You)
</strong>
</div>
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="user-item__button"
>
Admin
<i
class="CompassIcon icon-chevron-down CompassIcon"
/>
</button>
<div
class="Menu noselect left "
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div>
<div
aria-label="Viewer"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Viewer
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div />
<div>
<div
aria-label="Editor"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Editor
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Admin"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex menu-option__check"
>
<svg
class="CheckIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="20,60 40,80 80,40"
/>
</svg>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Admin
</div>
</div>
<div
class="noicon"
/>
</div>
</div>
<div>
<div
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div>
<div
aria-label="Remove member"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Remove member
</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>
`;

View File

@ -0,0 +1,172 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {Provider as ReduxProvider} from 'react-redux'
import thunk from 'redux-thunk'
import React from 'react'
import {MemoryRouter} from 'react-router'
import {mocked} from 'jest-mock'
import {BoardMember} from '../../blocks/board'
import {IUser} from '../../user'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {mockStateStore, wrapDNDIntl} from '../../testUtils'
import {Utils} from '../../utils'
import UserPermissionsRow from './userPermissionsRow'
jest.useFakeTimers()
const boardId = '1'
jest.mock('../../utils')
const mockedUtils = mocked(Utils, true)
const board = TestBlockFactory.createBoard()
board.id = boardId
board.teamId = 'team-id'
board.channelId = 'channel_1'
describe('src/components/shareBoard/userPermissionsRow', () => {
const me: IUser = {
id: 'user-id-1',
username: 'username_1',
email: '',
nickname: '',
firstname: '',
lastname: '',
props: {},
create_at: 0,
update_at: 0,
is_bot: false,
roles: 'system_user',
}
const state = {
teams: {
current: {id: 'team-id', title: 'Test Team'},
},
users: {
me,
boardUsers: [me],
blockSubscriptions: [],
},
boards: {
current: board.id,
boards: {
[board.id]: board,
},
templates: [],
membersInBoards: {
[board.id]: {},
},
myBoardMemberships: {
[board.id]: {userId: me.id, schemeAdmin: true},
},
},
}
beforeEach(() => {
jest.clearAllMocks()
})
test('should match snapshot', async () => {
let container: Element | undefined
mockedUtils.isFocalboardPlugin.mockReturnValue(false)
const store = mockStateStore([thunk], state)
await act(async () => {
const result = render(
wrapDNDIntl(
<ReduxProvider store={store}>
<UserPermissionsRow
user={me}
isMe={true}
member={state.boards.myBoardMemberships[board.id] as BoardMember}
teammateNameDisplay={'test'}
onDeleteBoardMember={() => {}}
onUpdateBoardMember={() => {}}
/>
</ReduxProvider>),
{wrapper: MemoryRouter},
)
container = result.container
})
const buttonElement = container?.querySelector('.user-item__button')
expect(buttonElement).toBeDefined()
userEvent.click(buttonElement!)
expect(container).toMatchSnapshot()
})
test('should match snapshot in plugin mode', async () => {
let container: Element | undefined
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
const store = mockStateStore([thunk], state)
await act(async () => {
const result = render(
wrapDNDIntl(
<ReduxProvider store={store}>
<UserPermissionsRow
user={me}
isMe={true}
member={state.boards.myBoardMemberships[board.id] as BoardMember}
teammateNameDisplay={'test'}
onDeleteBoardMember={() => {}}
onUpdateBoardMember={() => {}}/>
</ReduxProvider>),
{wrapper: MemoryRouter},
)
container = result.container
})
const buttonElement = container?.querySelector('.user-item__button')
expect(buttonElement).toBeDefined()
userEvent.click(buttonElement!)
expect(container).toMatchSnapshot()
})
test('should match snapshot in template', async () => {
let container: Element | undefined
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
const testState = {
...state,
boards: {
...state.boards,
boards: {},
templates: {
[board.id]: {...board, isTemplate: true},
}
}
}
const store = mockStateStore([thunk], testState)
await act(async () => {
const result = render(
wrapDNDIntl(
<ReduxProvider store={store}>
<UserPermissionsRow
user={me}
isMe={true}
member={state.boards.myBoardMemberships[board.id] as BoardMember}
teammateNameDisplay={'test'}
onDeleteBoardMember={() => {}}
onUpdateBoardMember={() => {}}
/>
</ReduxProvider>),
{wrapper: MemoryRouter},
)
container = result.container
})
const buttonElement = container?.querySelector('.user-item__button')
expect(buttonElement).toBeDefined()
userEvent.click(buttonElement!)
expect(container).toMatchSnapshot()
})
})

View File

@ -14,6 +14,8 @@ import {BoardMember} from '../../blocks/board'
import {IUser} from '../../user'
import {Utils} from '../../utils'
import {Permission} from '../../constants'
import {useAppSelector} from '../../store/hooks'
import {getCurrentBoard} from '../../store/boards'
import BoardPermissionGate from '../permissions/boardPermissionGate'
@ -28,6 +30,7 @@ type Props = {
const UserPermissionsRow = (props: Props): JSX.Element => {
const intl = useIntl()
const board = useAppSelector(getCurrentBoard)
const {user, member, isMe, teammateNameDisplay} = props
let currentRole = 'Viewer'
if (member.schemeAdmin) {
@ -71,6 +74,14 @@ const UserPermissionsRow = (props: Props): JSX.Element => {
name={intl.formatMessage({id: 'BoardMember.schemeViewer', defaultMessage: 'Viewer'})}
onClick={() => props.onUpdateBoardMember(member, 'Viewer')}
/>
{!board.isTemplate &&
<Menu.Text
id='Commenter'
check={true}
icon={currentRole === 'Commenter' ? <CheckIcon/> : null}
name={intl.formatMessage({id: 'BoardMember.schemeCommenter', defaultMessage: 'Commenter'})}
onClick={() => props.onUpdateBoardMember(member, 'Commenter')}
/>}
<Menu.Text
id='Editor'
check={true}

View File

@ -10,7 +10,9 @@ enum Permission {
ManageBoardRoles = 'manage_board_roles',
ManageBoardCards = 'manage_board_cards',
ManageBoardProperties = 'manage_board_properties',
CommentBoardCards = 'comment_board_cards',
ViewBoard = 'view_board',
DeleteOthersComments = 'delete_others_comments'
}
class Constants {

View File

@ -22,8 +22,9 @@ export const useHasPermissions = (teamId: string, boardId: string, permissions:
return true
}
const adminPermissions = [Permission.ManageBoardType, Permission.DeleteBoard, Permission.ShareBoard, Permission.ManageBoardRoles]
const adminPermissions = [Permission.ManageBoardType, Permission.DeleteBoard, Permission.ShareBoard, Permission.ManageBoardRoles, Permission.DeleteOthersComments]
const editorPermissions = [Permission.ManageBoardCards, Permission.ManageBoardProperties]
const commenterPermissions = [Permission.CommentBoardCards]
const viewerPermissions = [Permission.ViewBoard]
for (const permission of permissions) {
@ -33,6 +34,9 @@ export const useHasPermissions = (teamId: string, boardId: string, permissions:
if (editorPermissions.includes(permission) && (member.schemeAdmin || member.schemeEditor)) {
return true
}
if (commenterPermissions.includes(permission) && (member.schemeAdmin || member.schemeEditor || member.schemeCommenter)) {
return true
}
if (viewerPermissions.includes(permission) && (member.schemeAdmin || member.schemeEditor || member.schemeCommenter || member.schemeViewer)) {
return true
}