Merge pull request #2636 from mattermost/private-templates

Added support for access control on templates
This commit is contained in:
Scott Bishel 2022-03-31 07:42:58 -06:00 committed by GitHub
commit 36bf5704d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 75 additions and 35 deletions

View file

@ -2188,7 +2188,7 @@ func (a *API) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("teamID", teamID)
// retrieve boards list // retrieve boards list
boards, err := a.app.GetTemplateBoards(teamID) boards, err := a.app.GetTemplateBoards(teamID, userID)
if err != nil { if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return return

View file

@ -150,8 +150,8 @@ func (a *App) GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, er
return a.store.GetBoardsForUserAndTeam(userID, teamID) return a.store.GetBoardsForUserAndTeam(userID, teamID)
} }
func (a *App) GetTemplateBoards(teamID string) ([]*model.Board, error) { func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) {
return a.store.GetTemplateBoards(teamID) return a.store.GetTemplateBoards(teamID, userID)
} }
func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) { func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) {

View file

@ -46,14 +46,14 @@ func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, strin
} }
func (a *App) getOnboardingBoardID() (string, error) { func (a *App) getOnboardingBoardID() (string, error) {
boards, err := a.store.GetTemplateBoards(globalTeamID) boards, err := a.store.GetTemplateBoards(globalTeamID, "")
if err != nil { if err != nil {
return "", err return "", err
} }
var onboardingBoardID string var onboardingBoardID string
for _, block := range boards { for _, block := range boards {
if block.Title == WelcomeBoardTitle { if block.Title == WelcomeBoardTitle && block.TeamID == globalTeamID {
onboardingBoardID = block.ID onboardingBoardID = block.ID
break break
} }

View file

@ -25,7 +25,7 @@ func TestPrepareOnboardingTour(t *testing.T) {
IsTemplate: true, IsTemplate: true,
} }
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}},
nil, nil) nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil)
@ -60,7 +60,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
TeamID: "0", TeamID: "0",
IsTemplate: true, IsTemplate: true,
} }
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false). th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).
Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil) Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil)
@ -72,7 +72,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
t.Run("template doesn't contain a board", func(t *testing.T) { t.Run("template doesn't contain a board", func(t *testing.T) {
teamID := testTeamID teamID := testTeamID
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", teamID) boardID, err := th.App.createWelcomeBoard("user_id_1", teamID)
assert.Error(t, err) assert.Error(t, err)
assert.Empty(t, boardID) assert.Empty(t, boardID)
@ -86,7 +86,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
TeamID: teamID, TeamID: teamID,
IsTemplate: true, IsTemplate: true,
} }
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1") boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
assert.Error(t, err) assert.Error(t, err)
assert.Empty(t, boardID) assert.Empty(t, boardID)
@ -104,7 +104,7 @@ func TestGetOnboardingBoardID(t *testing.T) {
TeamID: "0", TeamID: "0",
IsTemplate: true, IsTemplate: true,
} }
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID() onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.NoError(t, err) assert.NoError(t, err)
@ -112,7 +112,7 @@ func TestGetOnboardingBoardID(t *testing.T) {
}) })
t.Run("no blocks found", func(t *testing.T) { t.Run("no blocks found", func(t *testing.T) {
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID() onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err) assert.Error(t, err)
@ -126,7 +126,7 @@ func TestGetOnboardingBoardID(t *testing.T) {
TeamID: "0", TeamID: "0",
IsTemplate: true, IsTemplate: true,
} }
th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID() onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err) assert.Error(t, err)

View file

@ -23,7 +23,7 @@ func (a *App) InitTemplates() error {
// initializeTemplates imports default templates if the boards table is empty. // initializeTemplates imports default templates if the boards table is empty.
func (a *App) initializeTemplates() (bool, error) { func (a *App) initializeTemplates() (bool, error) {
boards, err := a.store.GetTemplateBoards(globalTeamID) boards, err := a.store.GetTemplateBoards(globalTeamID, "")
if err != nil { if err != nil {
return false, fmt.Errorf("cannot initialize templates: %w", err) return false, fmt.Errorf("cannot initialize templates: %w", err)
} }

View file

@ -38,7 +38,7 @@ func TestApp_initializeTemplates(t *testing.T) {
th, tearDown := SetupTestHelper(t) th, tearDown := SetupTestHelper(t)
defer tearDown() defer tearDown()
th.Store.EXPECT().GetTemplateBoards(globalTeamID).Return([]*model.Board{}, nil) th.Store.EXPECT().GetTemplateBoards(globalTeamID, "").Return([]*model.Board{}, nil)
th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil) th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil)
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil) th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil)
@ -54,7 +54,7 @@ func TestApp_initializeTemplates(t *testing.T) {
th, tearDown := SetupTestHelper(t) th, tearDown := SetupTestHelper(t)
defer tearDown() defer tearDown()
th.Store.EXPECT().GetTemplateBoards(globalTeamID).Return([]*model.Board{board}, nil) th.Store.EXPECT().GetTemplateBoards(globalTeamID, "").Return([]*model.Board{board}, nil)
done, err := th.App.initializeTemplates() done, err := th.App.initializeTemplates()
require.NoError(t, err, "initializeTemplates should not error") require.NoError(t, err, "initializeTemplates should not error")

View file

@ -42,7 +42,7 @@ func setupTestHelper(t *testing.T) *TestHelper {
newAuth := New(&cfg, mockStore, localpermissions.New(mockPermissions, logger)) newAuth := New(&cfg, mockStore, localpermissions.New(mockPermissions, logger))
// called during default template setup for every test // called during default template setup for every test
mockStore.EXPECT().GetTemplateBoards(gomock.Any()).AnyTimes() mockStore.EXPECT().GetTemplateBoards("0", "").AnyTimes()
mockStore.EXPECT().RemoveDefaultTemplates(gomock.Any()).AnyTimes() mockStore.EXPECT().RemoveDefaultTemplates(gomock.Any()).AnyTimes()
mockStore.EXPECT().InsertBlock(gomock.Any(), gomock.Any()).AnyTimes() mockStore.EXPECT().InsertBlock(gomock.Any(), gomock.Any()).AnyTimes()

View file

@ -881,18 +881,18 @@ func (mr *MockStoreMockRecorder) GetTeamsForUser(arg0 interface{}) *gomock.Call
} }
// GetTemplateBoards mocks base method. // GetTemplateBoards mocks base method.
func (m *MockStore) GetTemplateBoards(arg0 string) ([]*model.Board, error) { func (m *MockStore) GetTemplateBoards(arg0, arg1 string) ([]*model.Board, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTemplateBoards", arg0) ret := m.ctrl.Call(m, "GetTemplateBoards", arg0, arg1)
ret0, _ := ret[0].([]*model.Board) ret0, _ := ret[0].([]*model.Board)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// GetTemplateBoards indicates an expected call of GetTemplateBoards. // GetTemplateBoards indicates an expected call of GetTemplateBoards.
func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0 interface{}) *gomock.Call { func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0, arg1)
} }
// GetUserByEmail mocks base method. // GetUserByEmail mocks base method.

View file

@ -435,8 +435,8 @@ func (s *SQLStore) GetTeamsForUser(userID string) ([]*model.Team, error) {
} }
func (s *SQLStore) GetTemplateBoards(teamID string) ([]*model.Board, error) { func (s *SQLStore) GetTemplateBoards(teamID string, userID string) ([]*model.Board, error) {
return s.getTemplateBoards(s.db, teamID) return s.getTemplateBoards(s.db, teamID, userID)
} }

View file

@ -54,13 +54,24 @@ func (s *SQLStore) removeDefaultTemplates(db sq.BaseRunner, boards []*model.Boar
return nil return nil
} }
// getDefaultTemplateBoards fetches all template blocks . // getTemplateBoards fetches all template boards .
func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID string) ([]*model.Board, error) { func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID, userID string) ([]*model.Board, error) {
query := s.getQueryBuilder(db). query := s.getQueryBuilder(db).
Select(boardFields("")...). Select(boardFields("")...).
From(s.tablePrefix + "boards"). From(s.tablePrefix+"boards as b").
Where(sq.Eq{"coalesce(team_id, '0')": teamID}). LeftJoin(s.tablePrefix+"board_members as bm on b.id = bm.board_id and bm.user_id = ?", userID).
Where(sq.Eq{"is_template": true}) Where(sq.Eq{"is_template": true}).
Where(sq.Eq{"b.team_id": teamID}).
Where(sq.Or{
// this is to include public templates even if there is not board_member entry
sq.And{
sq.Eq{"bm.board_id": nil},
sq.Eq{"b.type": model.BoardTypeOpen},
},
sq.And{
sq.NotEq{"bm.board_id": nil},
},
})
rows, err := query.Query() rows, err := query.Query()
if err != nil { if err != nil {
@ -69,5 +80,10 @@ func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID string) ([]*model.
} }
defer s.CloseRows(rows) defer s.CloseRows(rows)
return s.boardsFromRows(rows) userTemplates, err := s.boardsFromRows(rows)
if err != nil {
return nil, err
}
return userTemplates, nil
} }

View file

@ -129,7 +129,7 @@ type Store interface {
GetNextNotificationHint(remove bool) (*model.NotificationHint, error) GetNextNotificationHint(remove bool) (*model.NotificationHint, error)
RemoveDefaultTemplates(boards []*model.Board) error RemoveDefaultTemplates(boards []*model.Board) error
GetTemplateBoards(teamID string) ([]*model.Board, error) GetTemplateBoards(teamID, userID string) ([]*model.Board, error)
DBType() string DBType() string

View file

@ -181,6 +181,7 @@
"ShareBoard.tokenRegenrated": "Token regenerated", "ShareBoard.tokenRegenrated": "Token regenerated",
"ShareBoard.userPermissionsRemoveMemberText": "Remove member", "ShareBoard.userPermissionsRemoveMemberText": "Remove member",
"ShareBoard.userPermissionsYouText": "(You)", "ShareBoard.userPermissionsYouText": "(You)",
"ShareTemplate.Title": "Share Template",
"Sidebar.about": "About Focalboard", "Sidebar.about": "About Focalboard",
"Sidebar.add-board": "+ Add board", "Sidebar.add-board": "+ Add board",
"Sidebar.changePassword": "Change password", "Sidebar.changePassword": "Change password",

View file

@ -212,5 +212,16 @@
text-decoration: underline; text-decoration: underline;
} }
} }
.Menu {
position: fixed;
left: 55%;
right: calc(45% - 240px);
.menu-contents {
min-width: 240px;
max-width: 240px;
}
}
} }
} }

View file

@ -9,7 +9,7 @@ import Select from 'react-select/async'
import {CSSObject} from '@emotion/serialize' import {CSSObject} from '@emotion/serialize'
import {useAppSelector} from '../../store/hooks' import {useAppSelector} from '../../store/hooks'
import {getCurrentBoardId, getCurrentBoardMembers} from '../../store/boards' import {getCurrentBoard, getCurrentBoardMembers} from '../../store/boards'
import {getMe, getBoardUsersList} from '../../store/users' import {getMe, getBoardUsersList} from '../../store/users'
import {Utils, IDType} from '../../utils' import {Utils, IDType} from '../../utils'
@ -95,7 +95,8 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
// members of the current board // members of the current board
const members = useAppSelector<{[key: string]: BoardMember}>(getCurrentBoardMembers) const members = useAppSelector<{[key: string]: BoardMember}>(getCurrentBoardMembers)
const boardId = useAppSelector(getCurrentBoardId) const board = useAppSelector(getCurrentBoard)
const boardId = board.id
const boardUsers = useAppSelector<IUser[]>(getBoardUsersList) const boardUsers = useAppSelector<IUser[]>(getBoardUsersList)
const me = useAppSelector<IUser|null>(getMe) const me = useAppSelector<IUser|null>(getMe)
@ -239,7 +240,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
)) ))
} }
const toolbar = ( const shareBoardTitle = (
<span className='text-heading5'> <span className='text-heading5'>
<FormattedMessage <FormattedMessage
id={'ShareBoard.Title'} id={'ShareBoard.Title'}
@ -248,6 +249,17 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</span> </span>
) )
const shareTemplateTitle = (
<span className='text-heading5'>
<FormattedMessage
id={'ShareTemplate.Title'}
defaultMessage={'Share Template'}
/>
</span>
)
const toolbar = board.isTemplate ? shareTemplateTitle : shareBoardTitle
return ( return (
<Dialog <Dialog
onClose={props.onClose} onClose={props.onClose}
@ -299,7 +311,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
})} })}
</div> </div>
{props.enableSharedBoards && ( {props.enableSharedBoards && !board.isTemplate && (
<div className='tabs-container'> <div className='tabs-container'>
<button <button
onClick={() => setPublish(false)} onClick={() => setPublish(false)}
@ -323,7 +335,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</BoardPermissionGate> </BoardPermissionGate>
</div> </div>
)} )}
{(props.enableSharedBoards && publish) && {(props.enableSharedBoards && publish && !board.isTemplate) &&
(<BoardPermissionGate permissions={[Permission.ShareBoard]}> (<BoardPermissionGate permissions={[Permission.ShareBoard]}>
<div className='tabs-content'> <div className='tabs-content'>
<div> <div>
@ -401,7 +413,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</BoardPermissionGate> </BoardPermissionGate>
)} )}
{!publish && ( {!publish && !board.isTemplate && (
<div className='tabs-content'> <div className='tabs-content'>
<div> <div>
<div className='d-flex justify-content-between'> <div className='d-flex justify-content-between'>