1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-01-08 15:06:08 +02:00

Merge branch 'main' into rolling-stable

This commit is contained in:
Scott Bishel 2023-02-23 19:05:34 -07:00
commit c35bbdb436
51 changed files with 1451 additions and 135 deletions

View File

@ -45,7 +45,8 @@ const manifestStr = `
"type": "bool",
"help_text": "This allows board editors to share boards that can be accessed by anyone with the link.",
"placeholder": "",
"default": false
"default": false,
"hosting": ""
}
]
}

View File

@ -109,6 +109,104 @@ exports[`components/rhsChannelBoards renders the RHS for channel boards 1`] = `
</div>
`;
exports[`components/rhsChannelBoards renders the RHS for channel boards, no add 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="RHSChannelBoards"
>
<div
class="rhs-boards-header"
>
<span
class="linked-boards"
>
Linked boards
</span>
</div>
<div
class="rhs-boards-list"
>
<div
class="RHSChannelBoardItem"
>
<div
class="board-info"
>
<span
class="title"
>
Untitled board
</span>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="description"
/>
<div
class="date"
>
Last update at: July 08, 2022, 8:10 PM
</div>
</div>
<div
class="RHSChannelBoardItem"
>
<div
class="board-info"
>
<span
class="title"
>
Untitled board
</span>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="description"
/>
<div
class="date"
>
Last update at: July 08, 2022, 8:10 PM
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/rhsChannelBoards renders with empty list of boards 1`] = `
<div>
<div
@ -144,3 +242,31 @@ exports[`components/rhsChannelBoards renders with empty list of boards 1`] = `
</div>
</div>
`;
exports[`components/rhsChannelBoards renders with empty list of boards, cannot add 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="RHSChannelBoards empty"
>
<h2>
No boards are linked to Channel Name yet
</h2>
<div
class="empty-paragraph"
>
Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.
</div>
<div
class="boards-screenshots"
>
<img
src="test-file-stub"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -3,7 +3,7 @@
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {act, render} from '@testing-library/react'
import {act, render, screen} from '@testing-library/react'
import {mocked} from 'jest-mock'
import thunk from 'redux-thunk'
@ -44,6 +44,7 @@ describe('components/rhsChannelBoards', () => {
users: {
me: {
id: 'user-id',
permissions: ['create_post']
},
},
language: {
@ -89,7 +90,8 @@ describe('components/rhsChannelBoards', () => {
))
container = result.container
})
const buttonElement = screen.queryByText('Add')
expect(buttonElement).not.toBeNull()
expect(container).toMatchSnapshot()
})
@ -107,6 +109,45 @@ describe('components/rhsChannelBoards', () => {
container = result.container
})
const buttonElement = screen.queryByText('Link boards to Channel Name')
expect(buttonElement).not.toBeNull()
expect(container).toMatchSnapshot()
})
it('renders the RHS for channel boards, no add', async () => {
const localState = {...state, users: {me:{id: 'user-id'}}}
const store = mockStateStore([thunk], localState)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoards/>
</ReduxProvider>
))
container = result.container
})
const buttonElement = screen.queryByText('Add')
expect(buttonElement).toBeNull()
expect(container).toMatchSnapshot()
})
it('renders with empty list of boards, cannot add', async () => {
const localState = {...state, users: {me:{id: 'user-id'}}, boards: {...state.boards, boards: {}}}
const store = mockStateStore([thunk], localState)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoards/>
</ReduxProvider>
))
container = result.container
})
const buttonElement = screen.queryByText('Link boards to Channel Name')
expect(buttonElement).toBeNull()
expect(container).toMatchSnapshot()
})
})

View File

@ -49,7 +49,7 @@ const RHSChannelBoards = () => {
dispatch(loadMyBoardsMemberships()),
dispatch(fetchMe()),
]).then(() => setDataLoaded(true))
}, [])
}, [currentChannel?.id])
useWebsockets(teamId || '', (wsClient: WSClient) => {
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
@ -117,17 +117,19 @@ const RHSChannelBoards = () => {
/>
</div>
<div className='boards-screenshots'><img src={Utils.buildURL(boardsScreenshots, true)}/></div>
<Button
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
emphasis='primary'
size='medium'
>
<FormattedMessage
id='rhs-boards.link-boards-to-channel'
defaultMessage='Link boards to {channelName}'
values={{channelName: channelName}}
/>
</Button>
{me?.permissions?.find((s) => s === 'create_post') &&
<Button
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
emphasis='primary'
size='medium'
>
<FormattedMessage
id='rhs-boards.link-boards-to-channel'
defaultMessage='Link boards to {channelName}'
values={{channelName: channelName}}
/>
</Button>
}
</div>
</div>
)
@ -143,16 +145,18 @@ const RHSChannelBoards = () => {
defaultMessage='Linked boards'
/>
</span>
<Button
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
icon={<AddIcon/>}
emphasis='primary'
>
<FormattedMessage
id='rhs-boards.add'
defaultMessage='Add'
/>
</Button>
{me?.permissions?.find((s) => s === 'create_post') &&
<Button
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
icon={<AddIcon/>}
emphasis='primary'
>
<FormattedMessage
id='rhs-boards.add'
defaultMessage='Add'
/>
</Button>
}
</div>
<div className='rhs-boards-list'>
{channelBoards.map((b) => (

View File

@ -249,6 +249,7 @@ export default class Plugin {
if (lastViewedChannel !== currentChannel && currentChannel) {
localStorage.setItem('focalboardLastViewedChannel:' + currentUserId, currentChannel)
lastViewedChannel = currentChannel
octoClient.channelId = currentChannel
const currentChannelObj = mmStore.getState().entities.channels.channels[lastViewedChannel]
store.dispatch(setChannel(currentChannelObj))
}

View File

@ -206,6 +206,11 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
// description: Board ID
// required: true
// type: string
// - name: allow_admin
// in: path
// description: allows admin users to join private boards
// required: false
// type: boolean
// security:
// - BearerAuth: []
// responses:
@ -222,6 +227,9 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
// schema:
// "$ref": "#/definitions/ErrorResponse"
query := r.URL.Query()
allowAdmin := query.Has("allow_admin")
userID := getUserID(r)
if userID == "" {
a.errorResponse(w, r, model.NewErrBadRequest("missing user ID"))
@ -234,9 +242,14 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
a.errorResponse(w, r, err)
return
}
isAdmin := false
if board.Type != model.BoardTypeOpen {
a.errorResponse(w, r, model.NewErrPermission("cannot join a non Open board"))
return
if !allowAdmin || !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionManageTeam) {
a.errorResponse(w, r, model.NewErrPermission("cannot join a non Open board"))
return
}
isAdmin = true
}
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
@ -257,7 +270,7 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
newBoardMember := &model.BoardMember{
UserID: userID,
BoardID: boardID,
SchemeAdmin: board.MinimumRole == model.BoardRoleAdmin,
SchemeAdmin: board.MinimumRole == model.BoardRoleAdmin || isAdmin,
SchemeEditor: board.MinimumRole == model.BoardRoleNone || board.MinimumRole == model.BoardRoleEditor,
SchemeCommenter: board.MinimumRole == model.BoardRoleCommenter,
SchemeViewer: board.MinimumRole == model.BoardRoleViewer,

View File

@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"io"
"net/http"
"github.com/gorilla/mux"
@ -15,6 +16,7 @@ func (a *API) registerTeamsRoutes(r *mux.Router) {
r.HandleFunc("/teams", a.sessionRequired(a.handleGetTeams)).Methods("GET")
r.HandleFunc("/teams/{teamID}", a.sessionRequired(a.handleGetTeam)).Methods("GET")
r.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsers)).Methods("GET")
r.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsersByID)).Methods("POST")
r.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET")
}
@ -257,3 +259,106 @@ func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("userCount", len(users))
auditRec.Success()
}
func (a *API) handleGetTeamUsersByID(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /teams/{teamID}/users getTeamUsersByID
//
// Returns a user[]
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: Body
// in: body
// description: []UserIDs to return
// required: true
// type: []string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// type: array
// items:
// "$ref": "#/definitions/User"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
requestBody, err := io.ReadAll(r.Body)
if err != nil {
a.errorResponse(w, r, err)
return
}
var userIDs []string
if err = json.Unmarshal(requestBody, &userIDs); err != nil {
a.errorResponse(w, r, err)
return
}
auditRec := a.makeAuditRecord(r, "getTeamUsersByID", audit.Fail)
defer a.audit.LogRecord(audit.LevelRead, auditRec)
vars := mux.Vars(r)
teamID := vars["teamID"]
userID := getUserID(r)
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
return
}
var users []*model.User
var error error
if len(userIDs) == 0 {
a.errorResponse(w, r, model.NewErrBadRequest("User IDs are empty"))
return
}
if userIDs[0] == model.SingleUser {
ws, _ := a.app.GetRootTeam()
now := utils.GetMillis()
user := &model.User{
ID: model.SingleUser,
Username: model.SingleUser,
Email: model.SingleUser,
CreateAt: ws.UpdateAt,
UpdateAt: now,
}
users = append(users, user)
} else {
users, error = a.app.GetUsersList(userIDs)
if error != nil {
a.errorResponse(w, r, error)
return
}
for i, u := range users {
if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) {
users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id)
}
if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) {
users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id)
}
}
}
usersList, err := json.Marshal(users)
if err != nil {
a.errorResponse(w, r, err)
return
}
jsonStringResponse(w, http.StatusOK, string(usersList))
auditRec.Success()
}

View File

@ -107,6 +107,17 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: false
// type: string
// - name: channelID
// in: path
// description: Channel ID
// required: false
// type: string
// security:
// - BearerAuth: []
// responses:
@ -118,6 +129,9 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
query := r.URL.Query()
teamID := query.Get("teamID")
channelID := query.Get("channelID")
userID := getUserID(r)
@ -146,6 +160,16 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
}
}
if teamID != "" && a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionManageTeam) {
user.Permissions = append(user.Permissions, model.PermissionManageTeam.Id)
}
if a.permissions.HasPermissionTo(userID, model.PermissionManageSystem) {
user.Permissions = append(user.Permissions, model.PermissionManageSystem.Id)
}
if channelID != "" && a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionCreatePost) {
user.Permissions = append(user.Permissions, model.PermissionCreatePost.Id)
}
userData, err := json.Marshal(user)
if err != nil {
a.errorResponse(w, r, err)

View File

@ -502,11 +502,48 @@ func (a *App) DeleteBoard(boardID, userID string) error {
}
func (a *App) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) {
return a.store.GetMembersForBoard(boardID)
members, err := a.store.GetMembersForBoard(boardID)
if err != nil {
return nil, err
}
board, err := a.store.GetBoard(boardID)
if err != nil && !model.IsErrNotFound(err) {
return nil, err
}
if board != nil {
for i, m := range members {
if !m.SchemeAdmin {
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
members[i].SchemeAdmin = true
}
}
}
}
return members, nil
}
func (a *App) GetMembersForUser(userID string) ([]*model.BoardMember, error) {
return a.store.GetMembersForUser(userID)
members, err := a.store.GetMembersForUser(userID)
if err != nil {
return nil, err
}
for i, m := range members {
if !m.SchemeAdmin {
board, err := a.store.GetBoard(m.BoardID)
if err != nil && !model.IsErrNotFound(err) {
return nil, err
}
if board != nil {
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
// if system/team admin
members[i].SchemeAdmin = true
}
}
}
}
return members, nil
}
func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMember, error) {
@ -536,6 +573,14 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e
return nil, err
}
if !newMember.SchemeAdmin {
if board != nil {
if a.permissions.HasPermissionToTeam(newMember.UserID, board.TeamID, model.PermissionManageTeam) {
newMember.SchemeAdmin = true
}
}
}
if !board.IsTemplate {
if err = a.addBoardsToDefaultCategory(member.UserID, board.TeamID, []*model.Board{board}); err != nil {
return nil, err

View File

@ -127,6 +127,7 @@ func TestAddMemberToBoard(t *testing.T) {
},
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
th.API.EXPECT().HasPermissionToTeam("user_id_1", "team_id_1", model.PermissionManageTeam).Return(false).Times(1)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
@ -180,7 +181,7 @@ func TestPatchBoard(t *testing.T) {
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
@ -218,7 +219,7 @@ func TestPatchBoard(t *testing.T) {
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
@ -256,7 +257,7 @@ func TestPatchBoard(t *testing.T) {
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
@ -294,7 +295,7 @@ func TestPatchBoard(t *testing.T) {
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
@ -332,7 +333,10 @@ func TestPatchBoard(t *testing.T) {
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
}, nil).Times(3)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
@ -370,7 +374,11 @@ func TestPatchBoard(t *testing.T) {
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
ChannelID: "",
}, nil).Times(1)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
@ -566,3 +574,99 @@ func TestDuplicateBoard(t *testing.T) {
assert.NotNil(t, members)
})
}
func TestGetMembersForBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
const boardID = "board_id_1"
const userID = "user_id_1"
const teamID = "team_id_1"
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{
{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
},
}, nil).Times(3)
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil).Times(1)
t.Run("-base case", func(t *testing.T) {
members, err := th.App.GetMembersForBoard(boardID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
board := &model.Board{
ID: boardID,
TeamID: teamID,
}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
t.Run("-team check false ", func(t *testing.T) {
members, err := th.App.GetMembersForBoard(boardID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
t.Run("-team check true", func(t *testing.T) {
members, err := th.App.GetMembersForBoard(boardID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.True(t, members[0].SchemeAdmin)
})
}
func TestGetMembersForUser(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
const boardID = "board_id_1"
const userID = "user_id_1"
const teamID = "team_id_1"
th.Store.EXPECT().GetMembersForUser(userID).Return([]*model.BoardMember{
{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
},
}, nil).Times(3)
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil)
t.Run("-base case", func(t *testing.T) {
members, err := th.App.GetMembersForUser(userID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
board := &model.Board{
ID: boardID,
TeamID: teamID,
}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
t.Run("-team check false ", func(t *testing.T) {
members, err := th.App.GetMembersForUser(userID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
t.Run("-team check true", func(t *testing.T) {
members, err := th.App.GetMembersForUser(userID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.True(t, members[0].SchemeAdmin)
})
}

View File

@ -58,6 +58,7 @@ func TestGetUserCategoryBoards(t *testing.T) {
Synthetic: false,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
@ -151,6 +152,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: true,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -195,6 +197,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: false,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
@ -244,6 +247,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: true,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1"}).Return(nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{

View File

@ -10,6 +10,9 @@ import (
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/metrics"
"github.com/mattermost/focalboard/server/services/permissions/mmpermissions"
mmpermissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mmpermissions/mocks"
permissionsMocks "github.com/mattermost/focalboard/server/services/permissions/mocks"
"github.com/mattermost/focalboard/server/services/store/mockstore"
"github.com/mattermost/focalboard/server/services/webhook"
"github.com/mattermost/focalboard/server/ws"
@ -23,6 +26,7 @@ type TestHelper struct {
Store *mockstore.MockStore
FilesBackend *mocks.FileBackend
logger mlog.LoggerIFace
API *mmpermissionsMocks.MockAPI
}
func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
@ -37,6 +41,10 @@ func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
webhook := webhook.NewClient(&cfg, logger)
metricsService := metrics.NewMetrics(metrics.InstanceInfo{})
mockStore := permissionsMocks.NewMockStore(ctrl)
mockAPI := mmpermissionsMocks.NewMockAPI(ctrl)
permissions := mmpermissions.New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(true, mlog.LvlError))
appServices := Services{
Auth: auth,
Store: store,
@ -45,6 +53,7 @@ func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
Metrics: metricsService,
Logger: logger,
SkipTemplateInit: true,
Permissions: permissions,
}
app2 := New(&cfg, wsserver, appServices)
@ -60,5 +69,6 @@ func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
Store: store,
FilesBackend: filesBackend,
logger: logger,
API: mockAPI,
}, tearDown
}

View File

@ -39,7 +39,7 @@ func TestPrepareOnboardingTour(t *testing.T) {
nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
th.Store.EXPECT().GetMembersForBoard("board_id_2").Return([]*model.BoardMember{}, nil).Times(1)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(1)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(2)
th.Store.EXPECT().GetBoard("board_id_2").Return(&welcomeBoard, nil).Times(1)
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)

View File

@ -10,7 +10,20 @@ func (a *App) GetTeamUsers(teamID string, asGuestID string) ([]*model.User, erro
}
func (a *App) SearchTeamUsers(teamID string, searchQuery string, asGuestID string, excludeBots bool) ([]*model.User, error) {
return a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID, excludeBots, a.config.ShowEmailAddress, a.config.ShowFullName)
users, err := a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID, excludeBots, a.config.ShowEmailAddress, a.config.ShowFullName)
if err != nil {
return nil, err
}
for i, u := range users {
if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) {
users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id)
}
if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) {
users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id)
}
}
return users, nil
}
func (a *App) UpdateUserConfig(userID string, patch model.UserPreferencesPatch) ([]mmModel.Preference, error) {
@ -50,7 +63,18 @@ func (a *App) CanSeeUser(seerUser string, seenUser string) (bool, error) {
}
func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) {
return a.store.SearchUserChannels(teamID, userID, query)
channels, err := a.store.SearchUserChannels(teamID, userID, query)
if err != nil {
return nil, err
}
var writeableChannels []*mmModel.Channel
for _, channel := range channels {
if a.permissions.HasPermissionToChannel(userID, channel.Id, model.PermissionCreatePost) {
writeableChannels = append(writeableChannels, channel)
}
}
return writeableChannels, nil
}
func (a *App) GetChannel(teamID string, channelID string) (*mmModel.Channel, error) {

85
server/app/user_test.go Normal file
View File

@ -0,0 +1,85 @@
package app
import (
"testing"
"github.com/mattermost/focalboard/server/model"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSearchUsers(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.config.ShowEmailAddress = false
th.App.config.ShowFullName = false
teamID := "team-id-1"
userID := "user-id-1"
t.Run("return empty users", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{}, nil)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 0, len(users))
})
t.Run("return user", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 1, len(users))
assert.Equal(t, 0, len(users[0].Permissions))
})
t.Run("return team admin", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
th.App.config.ShowEmailAddress = false
th.App.config.ShowFullName = false
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 1, len(users))
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
})
t.Run("return system admin", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
th.App.config.ShowEmailAddress = false
th.App.config.ShowFullName = false
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(true).Times(1)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 1, len(users))
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
assert.Equal(t, users[0].Permissions[1], model.PermissionManageSystem.Id)
})
t.Run("test user channels", func(t *testing.T) {
channelID := "Channel1"
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mmModel.Channel{{Id: channelID}}, nil)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
channels, err := th.App.SearchUserChannels(teamID, userID, "")
assert.NoError(t, err)
assert.Equal(t, 1, len(channels))
})
t.Run("test user channels- no permissions", func(t *testing.T) {
channelID := "Channel1"
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mmModel.Channel{{Id: channelID}}, nil)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
channels, err := th.App.SearchUserChannels(teamID, userID, "")
assert.NoError(t, err)
assert.Equal(t, 0, len(channels))
})
}

View File

@ -78,6 +78,9 @@ func (*FakePermissionPluginAPI) HasPermissionTo(userID string, permission *mmMod
}
func (*FakePermissionPluginAPI) HasPermissionToTeam(userID string, teamID string, permission *mmModel.Permission) bool {
if permission.Id == model.PermissionManageTeam.Id {
return false
}
if userID == userNoTeamMember {
return false
}
@ -88,7 +91,7 @@ func (*FakePermissionPluginAPI) HasPermissionToTeam(userID string, teamID string
}
func (*FakePermissionPluginAPI) HasPermissionToChannel(userID string, channelID string, permission *mmModel.Permission) bool {
return channelID == "valid-channel-id"
return channelID == "valid-channel-id" || channelID == "valid-channel-id-2"
}
func getTestConfig() (*config.Configuration, error) {

View File

@ -6,7 +6,10 @@ import (
var (
PermissionViewTeam = mmModel.PermissionViewTeam
PermissionManageTeam = mmModel.PermissionManageTeam
PermissionManageSystem = mmModel.PermissionManageSystem
PermissionReadChannel = mmModel.PermissionReadChannel
PermissionCreatePost = mmModel.PermissionCreatePost
PermissionViewMembers = mmModel.PermissionViewMembers
PermissionCreatePublicChannel = mmModel.PermissionCreatePublicChannel
PermissionCreatePrivateChannel = mmModel.PermissionCreatePrivateChannel

View File

@ -66,6 +66,9 @@ type User struct {
// required: true
IsGuest bool `json:"is_guest"`
// Special Permissions the user may have
Permissions []string `json:"permissions,omitempty"`
Roles string `json:"roles"`
}

View File

@ -344,7 +344,7 @@ func appendContentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Dif
}
}
logger.Debug("appendContentChanges",
logger.Trace("appendContentChanges",
mlog.String("type", string(child.BlockType)),
mlog.String("opString", opString),
mlog.String("oldTitle", oldTitle),

View File

@ -31,6 +31,9 @@ func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel
if userID == "" || teamID == "" || permission == nil {
return false
}
if permission.Id == model.PermissionManageTeam.Id {
return false
}
return true
}

View File

@ -27,6 +27,11 @@ func TestHasPermissionToTeam(t *testing.T) {
hasPermission := th.permissions.HasPermissionToTeam("user-id", "team-id", model.PermissionManageBoardCards)
assert.True(t, hasPermission)
})
t.Run("no users have PermissionManageTeam on teams", func(t *testing.T) {
hasPermission := th.permissions.HasPermissionToTeam("user-id", "team-id", model.PermissionManageTeam)
assert.False(t, hasPermission)
})
}
func TestHasPermissionToBoard(t *testing.T) {
@ -141,4 +146,27 @@ func TestHasPermissionToBoard(t *testing.T) {
th.checkBoardPermissions("viewer", member, hasPermissionTo, hasNotPermissionTo)
})
t.Run("Manage Team Permission ", func(t *testing.T) {
member := &model.BoardMember{
UserID: "user-id",
BoardID: "board-id",
SchemeViewer: true,
}
hasPermissionTo := []*mmModel.Permission{
model.PermissionViewBoard,
}
hasNotPermissionTo := []*mmModel.Permission{
model.PermissionManageBoardType,
model.PermissionDeleteBoard,
model.PermissionManageBoardRoles,
model.PermissionShareBoard,
model.PermissionManageBoardCards,
model.PermissionManageBoardProperties,
}
th.checkBoardPermissions("viewer", member, hasPermissionTo, hasNotPermissionTo)
})
}

View File

@ -58,6 +58,13 @@ func (th *TestHelper) checkBoardPermissions(roleName string, member *model.Board
Return(member, nil).
Times(1)
if !member.SchemeAdmin {
th.api.EXPECT().
HasPermissionToTeam(member.UserID, teamID, model.PermissionManageTeam).
Return(roleName == "elevated-admin").
Times(1)
}
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p)
assert.True(t, hasPermission)
})
@ -80,6 +87,13 @@ func (th *TestHelper) checkBoardPermissions(roleName string, member *model.Board
Return(member, nil).
Times(1)
if !member.SchemeAdmin {
th.api.EXPECT().
HasPermissionToTeam(member.UserID, teamID, model.PermissionManageTeam).
Return(roleName == "elevated-admin").
Times(1)
}
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p)
assert.False(t, hasPermission)
})

View File

@ -82,7 +82,6 @@ func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmMod
if !s.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
return false
}
member, err := s.store.GetMemberForBoard(boardID, userID)
if model.IsErrNotFound(err) {
return false
@ -107,6 +106,13 @@ func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmMod
member.SchemeViewer = true
}
// Admins become member of boards, but get minimal role
// if they are a System/Team Admin (model.PermissionManageTeam)
// elevate their permissions
if !member.SchemeAdmin && s.HasPermissionToTeam(userID, board.TeamID, model.PermissionManageTeam) {
return true
}
switch permission {
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments:
return member.SchemeAdmin

View File

@ -219,4 +219,25 @@ func TestHasPermissionToBoard(t *testing.T) {
th.checkBoardPermissions("viewer", member, teamID, hasPermissionTo, hasNotPermissionTo)
})
t.Run("elevate board viewer permissions", func(t *testing.T) {
member := &model.BoardMember{
UserID: userID,
BoardID: boardID,
SchemeViewer: true,
}
hasPermissionTo := []*mmModel.Permission{
model.PermissionManageBoardType,
model.PermissionDeleteBoard,
model.PermissionManageBoardRoles,
model.PermissionShareBoard,
model.PermissionManageBoardCards,
model.PermissionViewBoard,
model.PermissionManageBoardProperties,
}
hasNotPermissionTo := []*mmModel.Permission{}
th.checkBoardPermissions("elevated-admin", member, teamID, hasPermissionTo, hasNotPermissionTo)
})
}

View File

@ -2,6 +2,7 @@ package utils
import (
"context"
"runtime/debug"
"sync/atomic"
"time"
@ -119,9 +120,11 @@ func (cn *CallbackQueue) exec(f CallbackFunc) {
// don't let a panic in the callback exit the thread.
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
cn.logger.Error("CallbackQueue callback panic",
mlog.String("name", cn.name),
mlog.Any("panic", r),
mlog.String("stack", string(stack)),
)
}
}()

View File

@ -454,7 +454,7 @@ func (pa *PluginAdapter) sendBoardMessage(teamID, boardID string, payload map[st
}
func (pa *PluginAdapter) BroadcastBlockChange(teamID string, block *model.Block) {
pa.logger.Debug("BroadcastingBlockChange",
pa.logger.Trace("BroadcastingBlockChange",
mlog.String("teamID", teamID),
mlog.String("boardID", block.BoardID),
mlog.String("blockID", block.ID),

View File

@ -41,16 +41,16 @@
"BoardsUnfurl.Updated": "Aktulaizirano {time}",
"Calculations.Options.average.displayName": "Prosjek",
"Calculations.Options.average.label": "Prosjek",
"Calculations.Options.count.displayName": "Zbroj",
"Calculations.Options.count.label": "Zbroj",
"Calculations.Options.countChecked.displayName": "Provjereno",
"Calculations.Options.countChecked.label": "Zbroj provjeren",
"Calculations.Options.countUnchecked.displayName": "Neprovjereno",
"Calculations.Options.countUnchecked.label": "Zbroj neprovjeren",
"Calculations.Options.count.displayName": "Broji",
"Calculations.Options.count.label": "Broji",
"Calculations.Options.countChecked.displayName": "Označeno",
"Calculations.Options.countChecked.label": "Broji označene",
"Calculations.Options.countUnchecked.displayName": "Neoznačeno",
"Calculations.Options.countUnchecked.label": "Broji neoznačene",
"Calculations.Options.countUniqueValue.displayName": "Jedinstveno",
"Calculations.Options.countUniqueValue.label": "Broji jedinstvene vrijednosti",
"Calculations.Options.countValue.displayName": "Vrijednosti",
"Calculations.Options.countValue.label": "Vrijednost zbroja",
"Calculations.Options.countValue.label": "Broji vrijednost",
"Calculations.Options.dateRange.displayName": "Raspon",
"Calculations.Options.dateRange.label": "Raspon",
"Calculations.Options.earliest.displayName": "Najraniji",

12
webapp/i18n/pt.json Normal file
View File

@ -0,0 +1,12 @@
{
"AppBar.Tooltip": "Alternar quadros vinculados",
"Attachment.Attachment-title": "Anexo",
"AttachmentBlock.DeleteAction": "Apagar",
"AttachmentBlock.addElement": "Adicionar {tipo}",
"AttachmentBlock.delete": "Anexo apagado.",
"AttachmentBlock.failed": "Este arquivo não pôde ser carregado pois ultrapassou o tamanho limite.",
"AttachmentBlock.upload": "Carregando anexo.",
"AttachmentBlock.uploadSuccess": "Anexo carregado.",
"AttachmentElement.delete-confirmation-dialog-button-text": "Apagar",
"AttachmentElement.download": "Baixar"
}

View File

@ -277,7 +277,7 @@
"SidebarTour.SidebarCategories.Body": "Alla dina boards är nu organiserade i ditt nya sidofält. Du behöver inte längre växla mellan olika arbetsområden. Anpassade kategorier baserade på dina tidigare arbetsytor kan ha skapats automatiskt för dig som en del av din uppgradering av v7.2. Dessa kan tas bort eller redigeras enligt dina önskemål.",
"SidebarTour.SidebarCategories.Link": "Mer information",
"SidebarTour.SidebarCategories.Title": "Kategorier i sidoomenyn",
"SiteStats.total_boards": "Totalt antal boards",
"SiteStats.total_boards": "Totalt antal tavlor",
"SiteStats.total_cards": "Totalt antal kort",
"TableComponent.add-icon": "Lägg till ikon",
"TableComponent.name": "Namn",

View File

@ -125,9 +125,113 @@
"ConfirmationDialog.confirm-action": "Підтвердити",
"ContentBlock.Delete": "Видалити",
"ContentBlock.DeleteAction": "видалити",
"ContentBlock.addElement": "додати {type}",
"ContentBlock.checkbox": "прапорець",
"ContentBlock.divider": "роздільник",
"ContentBlock.editCardCheckbox": "позначений прапорець",
"ContentBlock.editCardCheckboxText": "редагувати текст картки",
"ContentBlock.editCardText": "редагувати текст картки",
"ContentBlock.editText": "Редагувати текст...",
"ContentBlock.image": "зображення",
"ContentBlock.insertAbove": "Вставте вище",
"ContentBlock.moveBlock": "перемістити вміст картки",
"ContentBlock.moveDown": "Опустити",
"ContentBlock.moveUp": "Підняти",
"ContentBlock.text": "текст",
"DateRange.clear": "Очистити",
"DateRange.empty": "Пусто",
"DateRange.endDate": "Дата закінчення",
"DateRange.today": "Сьогодні",
"DeleteBoardDialog.confirm-cancel": "Скасувати",
"DeleteBoardDialog.confirm-delete": "Видалити",
"DeleteBoardDialog.confirm-info": "Ви впевнені, що хочете видалити дошку “{boardTitle}”? Видалення призведе до видалення всіх карток на дошці.",
"DeleteBoardDialog.confirm-info-template": "Ви впевнені, що хочете видалити шаблон дошки «{boardTitle}»?",
"DeleteBoardDialog.confirm-tite": "Підтвердьте видалення дошки",
"DeleteBoardDialog.confirm-tite-template": "Підтвердьте видалення шаблону дошки",
"Dialog.closeDialog": "Закрити діалогове вікно",
"EditableDayPicker.today": "Сьогодні",
"Error.mobileweb": "Мобільна веб-підтримка зараз знаходиться на ранній стадії бета-тестування. Не всі функції можуть бути присутніми.",
"Error.websocket-closed": "З'єднання через веб-сокет закрито, з'єднання перервано. Якщо це продовжується й далі, перевірте конфігурацію сервера або веб-проксі.",
"Filter.contains": "містить",
"Filter.ends-with": "закінчується на",
"Filter.includes": "включає в себе",
"Filter.is": "є",
"Filter.is-empty": "пусто",
"Filter.is-not-empty": "не порожній",
"Filter.is-not-set": "не встановлено",
"Filter.is-set": "встановлено",
"Filter.not-contains": "не містить",
"Filter.not-ends-with": "не закінчується",
"Filter.not-includes": "не включає",
"Filter.not-starts-with": "не починається з",
"Filter.starts-with": "починається з",
"FilterByText.placeholder": "фільтрувати текст",
"FilterComponent.add-filter": "+ Додати фільтр",
"FilterComponent.delete": "Видалити",
"FilterValue.empty": "(порожній)",
"FindBoardsDialog.IntroText": "Пошук дощок",
"FindBoardsDialog.NoResultsFor": "Немає результатів для \"{searchQuery}\"",
"FindBoardsDialog.NoResultsSubtext": "Перевірте правильність написання або спробуйте інший запит.",
"FindBoardsDialog.SubTitle": "Введіть, щоб знайти дошку. Використовуйте <b>ВГОРУ/ВНИЗ</b> для перегляду. <b>ENTER</b>, щоб вибрати, <b>ESC</b>, щоб закрити",
"FindBoardsDialog.Title": "Знайти дошки",
"GroupBy.hideEmptyGroups": "Сховати {count} порожні групи",
"GroupBy.showHiddenGroups": "Показати {count} прихованих груп",
"GroupBy.ungroup": "Розгрупувати",
"HideBoard.MenuOption": "Сховати дошку",
"KanbanCard.untitled": "Без назви",
"MentionSuggestion.is-not-board-member": "(не член правління)",
"Mutator.new-board-from-template": "нова дошка з шаблону",
"Mutator.new-card-from-template": "нова картка із шаблону",
"Mutator.new-template-from-card": "новий шаблон із картки",
"OnboardingTour.AddComments.Body": "Ви можете коментувати проблеми та навіть @згадувати інших користувачів Mattermost, щоб привернути їх увагу.",
"OnboardingTour.AddComments.Title": "Додати коментарі",
"OnboardingTour.AddDescription.Body": "Додайте опис до картки, щоб ваші товариші по команді знали, про що йде мова.",
"OnboardingTour.AddDescription.Title": "Додайте опис",
"OnboardingTour.AddProperties.Body": "Додайте карткам різні властивості, щоб зробити їх потужнішими.",
"OnboardingTour.AddProperties.Title": "Додайте властивості",
"OnboardingTour.AddView.Body": "Перейдіть сюди, щоб створити новий вид для організації дошки за допомогою різних макетів.",
"OnboardingTour.AddView.Title": "Додайте новий вид",
"OnboardingTour.CopyLink.Body": "Ви можете поділитися своїми картками з товаришами по команді, скопіювавши посилання та вставивши його в канал, пряме або групове повідомлення.",
"OnboardingTour.CopyLink.Title": "Копіювати посилання",
"OnboardingTour.OpenACard.Body": "Відкрийте картку, щоб дослідити потужні способи, за допомогою яких дошки можуть допомогти вам організувати вашу роботу.",
"OnboardingTour.OpenACard.Title": "Відкрити картку",
"OnboardingTour.ShareBoard.Body": "Ви можете поділитися своєю дошкою всередині, у своїй команді або опублікувати її публічно для видимості за межами вашої організації.",
"OnboardingTour.ShareBoard.Title": "Поділитися дошкою",
"PersonProperty.board-members": "Члени команди",
"PersonProperty.me": "Я",
"PersonProperty.non-board-members": "Не учасник команди",
"PropertyMenu.Delete": "Видалити",
"PropertyMenu.changeType": "Змінити тип власності",
"PropertyMenu.selectType": "Виберіть тип властивості",
"PropertyMenu.typeTitle": "Тип",
"PropertyType.Checkbox": "Прапорець",
"PropertyType.CreatedBy": "Створений",
"PropertyType.CreatedTime": "Час створення",
"PropertyType.Date": "Дата",
"PropertyType.Email": "Email",
"PropertyType.MultiPerson": "Кілька осіб",
"PropertyType.MultiSelect": "Множинний вибір",
"PropertyType.Number": "Номер",
"PropertyType.Person": "Особа",
"PropertyType.Phone": "Телефон",
"PropertyType.Select": "Обрати",
"PropertyType.Text": "Текст",
"PropertyType.Unknown": "Невідомий",
"PropertyType.UpdatedBy": "Оновлено користувачем",
"PropertyType.UpdatedTime": "Час останнього оновлення",
"PropertyType.Url": "URL",
"PropertyValueElement.empty": "Пусто",
"RegistrationLink.confirmRegenerateToken": "Це призведе до скасування попередніх спільних посилань. Продовжити?",
"RegistrationLink.copiedLink": "Скопійовано!",
"RegistrationLink.copyLink": "Копіювати посилання",
"RegistrationLink.description": "Поділіться цим посиланням, щоб інші могли створити облікові записи:",
"RegistrationLink.regenerateToken": "Згенерувати новий токен",
"RegistrationLink.tokenRegenerated": "Реєстраційне посилання відновлено",
"ShareBoard.PublishDescription": "Опублікуйте та поділіться посиланням лише для читання з усіма в Інтернеті.",
"ShareBoard.PublishTitle": "Опублікувати в Інтернеті",
"ShareBoard.ShareInternal": "Поділитися всередині організації",
"ShareBoard.ShareInternalDescription": "Користувачі, які мають дозволи, зможуть використовувати це посилання.",
"ShareBoard.Title": "Поділиться Дошкою",
"Sidebar.delete-board": "Видалити дошку",
"SidebarCategories.CategoryMenu.Delete": "Видалити категорію",
"SidebarCategories.CategoryMenu.DeleteModal.Title": "Видалити дану категорію?",

View File

@ -3,7 +3,7 @@
"Attachment.Attachment-title": "附件",
"AttachmentBlock.DeleteAction": "刪除",
"AttachmentBlock.addElement": "添加 {type}",
"AttachmentBlock.delete": "附件刪除成功。",
"AttachmentBlock.delete": "已刪除附件",
"AttachmentBlock.failed": "無法上傳文件。 附件大小已達到限制。",
"AttachmentBlock.upload": "附件正在上傳。",
"AttachmentBlock.uploadSuccess": "附件已上傳",
@ -88,7 +88,7 @@
"CardDetail.add-icon": "新增圖示",
"CardDetail.add-property": "+ 新增屬性",
"CardDetail.addCardText": "新增卡片文本",
"CardDetail.limited-body": "升級到專業版或是企業計劃,以查看封存卡片,獲得無限看,無限卡片和更多功能。",
"CardDetail.limited-body": "升級到專業版或是企業版",
"CardDetail.limited-button": "升級",
"CardDetail.limited-title": "此卡片被影藏",
"CardDetail.moveContent": "移動卡片內容",
@ -103,9 +103,9 @@
"CardDetailProperty.property-deleted": "成功刪除 {propertyName}!",
"CardDetailProperty.property-name-change-subtext": "類型從 \"{oldPropType}\" 變更為 \"{newPropType}\"",
"CardDetial.limited-link": "了解更多我們的計畫.",
"CardDialog.delete-confirmation-dialog-attachment": "確認刪除附件",
"CardDialog.delete-confirmation-dialog-attachment": "確認刪除附件",
"CardDialog.delete-confirmation-dialog-button-text": "刪除",
"CardDialog.delete-confirmation-dialog-heading": "確認刪除卡片",
"CardDialog.delete-confirmation-dialog-heading": "確認刪除卡片",
"CardDialog.editing-template": "您正在編輯範本。",
"CardDialog.nocard": "卡片不存在或者無法被存取。",
"Categories.CreateCategoryDialog.CancelText": "取消",
@ -114,10 +114,13 @@
"Categories.CreateCategoryDialog.UpdateText": "更新",
"CenterPanel.Login": "登入",
"CenterPanel.Share": "分享",
"ChannelIntro.CreateBoard": "建立看板",
"CloudMessage.cloud-server": "獲得免費的雲端伺服器.",
"ColorOption.selectColor": "{color} 選擇顏色",
"Comment.delete": "刪除",
"CommentsList.send": "發送",
"ConfirmPerson.empty": "空白",
"ConfirmPerson.search": "查詢...",
"ConfirmationDialog.cancel-action": "取消",
"ConfirmationDialog.confirm-action": "確認",
"ContentBlock.Delete": "刪除",
@ -165,6 +168,7 @@
"FilterByText.placeholder": "過濾文字",
"FilterComponent.add-filter": "+ 增加過濾條件",
"FilterComponent.delete": "刪除",
"FilterValue.empty": "(空白)",
"FindBoardsDialog.IntroText": "查詢看板",
"FindBoardsDialog.NoResultsFor": "「{searchQuery}」搜尋未果",
"FindBoardsDialog.NoResultsSubtext": "檢查錯字或嘗試其他搜尋.",
@ -183,7 +187,7 @@
"OnboardingTour.AddComments.Title": "新增評論",
"OnboardingTour.AddDescription.Body": "在卡片上新增描述讓其他成員知道此卡片內容.",
"OnboardingTour.AddDescription.Title": "新增敘述",
"OnboardingTour.AddProperties.Body": "為卡片新增各式屬性使其更加強大!",
"OnboardingTour.AddProperties.Body": "為卡片新增各式屬性使其更加強大",
"OnboardingTour.AddProperties.Title": "新增屬性",
"OnboardingTour.AddView.Body": "轉到此處創建一個新視圖以使用不同的佈局組織您的看板。",
"OnboardingTour.AddView.Title": "新增視圖",
@ -193,7 +197,8 @@
"OnboardingTour.OpenACard.Title": "瀏覽卡片",
"OnboardingTour.ShareBoard.Body": "您可以在內部、團隊內部分享看板,或公開發布讓組織外部查看。",
"OnboardingTour.ShareBoard.Title": "分享看板",
"PersonProperty.board-members": "看版成員",
"PersonProperty.board-members": "看板成員",
"PersonProperty.me": "我",
"PersonProperty.non-board-members": "不是看板成員",
"PropertyMenu.Delete": "刪除",
"PropertyMenu.changeType": "修改屬性類型",
@ -273,7 +278,7 @@
"SidebarTour.SidebarCategories.Link": "更多",
"SidebarTour.SidebarCategories.Title": "邊欄類別",
"SiteStats.total_boards": "所有看板",
"SiteStats.total_cards": "總數",
"SiteStats.total_cards": "總卡片數",
"TableComponent.add-icon": "加入圖示",
"TableComponent.name": "姓名",
"TableComponent.plus-new": "+ 新增",
@ -284,6 +289,7 @@
"TableHeaderMenu.insert-right": "在右側插入",
"TableHeaderMenu.sort-ascending": "升序排列",
"TableHeaderMenu.sort-descending": "降序排列",
"TableRow.DuplicateCard": "複製卡片",
"TableRow.MoreOption": "更多操作",
"TableRow.open": "開啟",
"TopBar.give-feedback": "提供回饋",
@ -336,9 +342,9 @@
"ViewLimitDialog.Heading": "已達到每個看板觀看限制",
"ViewLimitDialog.PrimaryButton.Title.Admin": "升級",
"ViewLimitDialog.PrimaryButton.Title.RegularUser": "通知管理者",
"ViewLimitDialog.Subtext.Admin": "升級到專業版或企業版,獲得每個看板無限瀏覽、無限卡片,以及更多。",
"ViewLimitDialog.Subtext.Admin": "升級到專業版或企業版",
"ViewLimitDialog.Subtext.Admin.PricingPageLink": "了解更多我們的計畫。",
"ViewLimitDialog.Subtext.RegularUser": "通知你的管理員升級到專業版或是企業版,獲得無限使用看板、卡片、更多。",
"ViewLimitDialog.Subtext.RegularUser": "通知你的管理員升級到專業版或是企業版",
"ViewLimitDialog.UpgradeImg.AltText": "升級圖片",
"ViewLimitDialog.notifyAdmin.Success": "已經通知管理者",
"ViewTitle.hide-description": "隱藏敘述",
@ -355,17 +361,19 @@
"Workspace.editing-board-template": "您正在編輯版面範本。",
"badge.guest": "訪客",
"boardSelector.confirm-link-board": "連結看板與頻道",
"boardSelector.confirm-link-board-button": "是,連結看",
"boardSelector.confirm-link-board-button": "是,連結看",
"boardSelector.confirm-link-board-subtext": "當你將\"{boardName}\"連接到頻道時,該頻道的所有成員(現有的和新的)都可以編輯。並不包含訪客身分。你可以在任何時候從一個頻道上取消看板的連接。",
"boardSelector.confirm-link-board-subtext-with-other-channel": "當你將\"{boardName}\"連接到頻道時,該頻道的所有成員(現有的和新的)都可以編輯。並不包含訪客身分。{lineBreak} 看板目前正連接到另一個頻道。如果选择在這裡連接它,將取消另一個連接。",
"boardSelector.create-a-board": "建立看",
"boardSelector.create-a-board": "建立看",
"boardSelector.link": "連結",
"boardSelector.search-for-boards": "搜尋看板",
"boardSelector.title": "連結看",
"boardSelector.title": "連結看",
"boardSelector.unlink": "未連結",
"calendar.month": "月份",
"calendar.today": "今日",
"calendar.week": "週別",
"centerPanel.undefined": "沒有 {propertyName}",
"centerPanel.unknown-user": "未知使用者",
"cloudMessage.learn-more": "學習更多",
"createImageBlock.failed": "無法上傳檔案,檔案大小超過限制。",
"default-properties.badges": "評論和描述",
@ -374,19 +382,23 @@
"error.back-to-team": "回到團隊",
"error.board-not-found": "沒有找到看板.",
"error.go-login": "登入",
"error.invalid-read-only-board": "沒有權限進入此看.登入後才能訪問.",
"error.invalid-read-only-board": "沒有權限進入此看.登入後才能訪問.",
"error.not-logged-in": "已被登出,請再次登入使用看板。",
"error.page.title": "很抱歉,發生了些錯誤",
"error.team-undefined": "不是有效的團隊。",
"error.unknown": "發生一個錯誤。",
"generic.previous": "上一篇",
"guest-no-board.subtitle": "你尚未有權限進入此看板,請等人把你加入任何看板。",
"guest-no-board.title": "尚未有看",
"guest-no-board.title": "尚未有看",
"imagePaste.upload-failed": "有些檔案無法上傳.檔案大小達上限",
"limitedCard.title": "影藏卡片",
"login.log-in-button": "登錄",
"login.log-in-title": "登錄",
"login.register-button": "或創建一個帳戶(如果您沒有帳戶)",
"new_channel_modal.create_board.empty_board_description": "建立新的空白看板",
"new_channel_modal.create_board.empty_board_title": "空白看板",
"new_channel_modal.create_board.select_template_placeholder": "選擇一個範本",
"new_channel_modal.create_board.title": "在這個頻道新建一個看板",
"notification-box-card-limit-reached.close-tooltip": "小睡十天",
"notification-box-card-limit-reached.contact-link": "通知管理員",
"notification-box-card-limit-reached.link": "升級到付費版",
@ -412,8 +424,8 @@
"rhs-boards.linked-boards": "連結看板",
"rhs-boards.no-boards-linked-to-channel": "還沒有看板與{channelName}連接",
"rhs-boards.no-boards-linked-to-channel-description": "看板是一個專案管理工具,可以使用熟悉的圖表幫助我們定義、組織、追蹤和管理跨團隊工作。",
"rhs-boards.unlink-board": "未連結看",
"rhs-boards.unlink-board1": "未連結看",
"rhs-boards.unlink-board": "未連結看",
"rhs-boards.unlink-board1": "未連結看",
"rhs-channel-boards-header.title": "板塊",
"share-board.publish": "發布",
"share-board.share": "分享",

View File

@ -107,6 +107,7 @@ const BoardsSwitcher = (props: Props): JSX.Element => {
inverted={true}
className='add-board-icon'
icon={<AddIcon/>}
title={'Add Board Dropdown'}
/>
<Menu>
<Menu.Text

View File

@ -1991,6 +1991,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
>
@username_1
</strong>
<div
class="AdminBadge"
>
<div
class="AdminBadge__box"
>
Team Admin
</div>
</div>
</div>
</div>
</div>
@ -2017,6 +2026,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
>
@username_2
</strong>
<div
class="AdminBadge"
>
<div
class="AdminBadge__box"
>
Admin
</div>
</div>
</div>
</div>
</div>

View File

@ -728,3 +728,263 @@ exports[`src/components/shareBoard/userPermissionsRow should match snapshot in t
</div>
</div>
`;
exports[`src/components/shareBoard/userPermissionsRow should match snapshot-admin 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
class="AdminBadge"
>
<div
class="AdminBadge__box"
>
Admin
</div>
</div>
</div>
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper override menuOpened"
role="button"
>
<button
class="user-item__button"
>
Admin
<i
class="CompassIcon icon-chevron-down CompassIcon"
/>
</button>
<div
class="Menu noselect left "
style="top: 40px;"
>
<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="menu-option__icon"
>
<div
class="empty-icon"
/>
</div>
</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="menu-option__icon"
>
<div
class="empty-icon"
/>
</div>
</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="menu-option__icon"
>
<div
class="empty-icon"
/>
</div>
</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"
>
<div
class="menu-option__icon"
>
<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>
<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

@ -548,8 +548,8 @@ describe('src/components/shareBoard/shareBoard', () => {
}
mockedOctoClient.getSharing.mockResolvedValue(sharing)
const users: IUser[] = [
{id: 'userid1', username: 'username_1'} as IUser,
{id: 'userid2', username: 'username_2'} as IUser,
{id: 'userid1', username: 'username_1', permissions: ['manage_team']} as IUser,
{id: 'userid2', username: 'username_2', permissions: ['manage_system']} as IUser,
{id: 'userid3', username: 'username_3'} as IUser,
{id: 'userid4', username: 'username_4'} as IUser,
]

View File

@ -32,6 +32,7 @@ import Button from '../../widgets/buttons/button'
import {sendFlashMessage} from '../flashMessages'
import {Permission} from '../../constants'
import GuestBadge from '../../widgets/guestBadge'
import AdminBadge from '../../widgets/adminBadge/adminBadge'
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
@ -310,6 +311,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
<strong>{Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay)}</strong>
<strong className='ml-2 text-light'>{`@${user.username}`}</strong>
<GuestBadge show={Boolean(user?.is_guest)}/>
<AdminBadge permissions={user.permissions}/>
</div>
</div>
)

View File

@ -104,6 +104,38 @@ describe('src/components/shareBoard/userPermissionsRow', () => {
expect(container).toMatchSnapshot()
})
test('should match snapshot-admin', async () => {
let container: Element | undefined
mockedUtils.isFocalboardPlugin.mockReturnValue(false)
const store = mockStateStore([thunk], state)
const newMe = Object.assign({}, me)
newMe.permissions = ['manage_system']
await act(async () => {
const result = render(
wrapDNDIntl(
<ReduxProvider store={store}>
<UserPermissionsRow
user={newMe}
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)

View File

@ -15,6 +15,7 @@ import {IUser} from '../../user'
import {Utils} from '../../utils'
import {Permission} from '../../constants'
import GuestBadge from '../../widgets/guestBadge'
import AdminBadge from '../../widgets/adminBadge/adminBadge'
import {useAppSelector} from '../../store/hooks'
import {getCurrentBoard} from '../../store/boards'
@ -65,6 +66,7 @@ const UserPermissionsRow = (props: Props): JSX.Element => {
<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}/>
<AdminBadge permissions={user.permissions}/>
</div>
</div>
<div>

View File

@ -8,6 +8,7 @@ enum Permission {
DeleteBoard = 'delete_board',
ShareBoard = 'share_board',
ManageBoardRoles = 'manage_board_roles',
ChannelCreatePost = 'create_post',
ManageBoardCards = 'manage_board_cards',
ManageBoardProperties = 'manage_board_properties',
CommentBoardCards = 'comment_board_cards',
@ -196,6 +197,7 @@ class Constants {
}
static readonly globalTeamId = '0'
static readonly noChannelID = '0'
static readonly myInsights = 'MY'

View File

@ -51,7 +51,7 @@ class OctoClient {
localStorage.setItem('focalboardSessionId', value)
}
constructor(serverUrl?: string, public teamId = Constants.globalTeamId) {
constructor(serverUrl?: string, public teamId = Constants.globalTeamId, public channelId = Constants.noChannelID) {
this.serverUrl = serverUrl
}
@ -160,7 +160,22 @@ class OctoClient {
}
async getMe(): Promise<IUser | undefined> {
const path = '/api/v2/users/me'
let path = '/api/v2/users/me'
let parameters = ''
if (this.teamId !== Constants.globalTeamId) {
parameters = `teamID=${this.teamId}`
}
if (this.channelId !== Constants.noChannelID) {
const channelClause = `channelID=${this.channelId}`
if (parameters) {
parameters += '&' + channelClause
} else {
parameters = channelClause
}
}
if (parameters) {
path += '?' + parameters
}
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
if (response.status !== 200) {
return undefined
@ -467,12 +482,15 @@ class OctoClient {
return this.getJson<BoardMember>(response, {} as BoardMember)
}
async joinBoard(boardId: string): Promise<BoardMember|undefined> {
async joinBoard(boardId: string, allowAdmin: boolean): Promise<BoardMember|undefined> {
Utils.log(`joinBoard: board ${boardId}`)
const response = await fetch(this.getBaseURL() + `/api/v2/boards/${boardId}/join`, {
method: 'POST',
let path = `/api/v2/boards/${boardId}/join`
if (allowAdmin) {
path += '?allow_admin'
}
const response = await fetch(this.getBaseURL() + path, {
headers: this.headers(),
method: 'POST',
})
if (response.status !== 200) {
@ -680,6 +698,22 @@ class OctoClient {
return (await this.getJson(response, [])) as IUser[]
}
async getTeamUsersList(userIds: string[], teamId: string): Promise<IUser[] | []> {
const path = this.teamPath(teamId) + '/users'
const body = JSON.stringify(userIds)
const response = await fetch(this.getBaseURL() + path, {
headers: this.headers(),
method: 'POST',
body,
})
if (response.status !== 200) {
return []
}
return (await this.getJson(response, [])) as IUser[]
}
async searchTeamUsers(searchQuery: string, excludeBots?: boolean): Promise<IUser[]> {
let path = this.teamPath() + `/users?search=${searchQuery}`
if (excludeBots) {

View File

@ -3,7 +3,7 @@
import React, {useEffect, useState, useMemo, useCallback} from 'react'
import {batch} from 'react-redux'
import {FormattedMessage, useIntl} from 'react-intl'
import {useRouteMatch} from 'react-router-dom'
import {useRouteMatch, useHistory} from 'react-router-dom'
import Workspace from '../../components/workspace'
import CloudMessage from '../../components/messages/cloudMessage'
@ -29,6 +29,7 @@ import {
addMyBoardMemberships,
} from '../../store/boards'
import {getCurrentViewId, setCurrent as setCurrentView, updateViews} from '../../store/views'
import ConfirmationDialog from '../../components/confirmationDialogBox'
import {initialLoad, initialReadOnlyLoad, loadBoardData} from '../../store/initialLoad'
import {useAppSelector, useAppDispatch} from '../../store/hooks'
import {setTeam} from '../../store/teams'
@ -79,6 +80,8 @@ const BoardPage = (props: Props): JSX.Element => {
const me = useAppSelector<IUser|null>(getMe)
const hiddenBoardIDs = useAppSelector(getHiddenBoardIDs)
const category = useAppSelector(getCategoryOfBoard(activeBoardId))
const [showJoinBoardDialog, setShowJoinBoardDialog] = useState<boolean>(false)
const history = useHistory()
// if we're in a legacy route and not showing a shared board,
// redirect to the new URL schema equivalent
@ -177,18 +180,40 @@ const BoardPage = (props: Props): JSX.Element => {
}
}, [me?.id, activeBoardId])
const loadOrJoinBoard = useCallback(async (userId: string, boardTeamId: string, boardId: string) => {
// and fetch its data
const result: any = await dispatch(loadBoardData(boardId))
if (result.payload.blocks.length === 0 && userId) {
const member = await octoClient.joinBoard(boardId)
if (!member) {
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError('board-not-found'))
const onConfirmJoin = async () => {
if (me) {
joinBoard(me, teamId, match.params.boardId, true)
setShowJoinBoardDialog(false)
}
}
const joinBoard = async (myUser: IUser, boardTeamId: string, boardId: string, allowAdmin: boolean) => {
const member = await octoClient.joinBoard(boardId, allowAdmin)
if (!member) {
if (myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) {
setShowJoinBoardDialog(true)
return
}
await dispatch(loadBoardData(boardId))
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError('board-not-found'))
return
}
const result: any = await dispatch(loadBoardData(boardId))
if (result.payload.blocks.length > 0 && myUser.id) {
// set board as most recently viewed board
UserSettings.setLastBoardID(boardTeamId, boardId)
}
}
const loadOrJoinBoard = useCallback(async (myUser: IUser, boardTeamId: string, boardId: string) => {
// and fetch its data
const result: any = await dispatch(loadBoardData(boardId))
if (result.payload.blocks.length === 0 && myUser.id) {
joinBoard(myUser, boardTeamId, boardId, false)
} else {
// set board as most recently viewed board
UserSettings.setLastBoardID(boardTeamId, boardId)
}
dispatch(fetchBoardMembers({
@ -204,9 +229,6 @@ const BoardPage = (props: Props): JSX.Element => {
// set the active board
dispatch(setCurrentBoard(match.params.boardId))
// and set it as most recently viewed board
UserSettings.setLastBoardID(teamId, match.params.boardId)
if (viewId !== Constants.globalTeamId) {
// reset current, even if empty string
dispatch(setCurrentView(viewId))
@ -220,7 +242,7 @@ const BoardPage = (props: Props): JSX.Element => {
useEffect(() => {
if (match.params.boardId && !props.readonly && me) {
loadOrJoinBoard(me.id, teamId, match.params.boardId)
loadOrJoinBoard(me, teamId, match.params.boardId)
}
}, [teamId, match.params.boardId, me?.id])
@ -251,49 +273,71 @@ const BoardPage = (props: Props): JSX.Element => {
}
return (
<div className='BoardPage'>
{!props.new && <TeamToBoardAndViewRedirect/>}
<BackwardCompatibilityQueryParamsRedirect/>
<SetWindowTitleAndIcon/>
<UndoRedoHotKeys/>
<WebsocketConnection/>
<CloudMessage/>
<VersionMessage/>
<>
{showJoinBoardDialog &&
<ConfirmationDialog
dialogBox={{
heading: intl.formatMessage({id: 'boardPage.confirm-join-title', defaultMessage: 'Join private board'}),
subText: intl.formatMessage({
id: 'boardPage.confirm-join-text',
defaultMessage: 'You are about to join a private board without explicitly being added by the board admin. Are you sure you wish to join this private board?',
}),
confirmButtonText: intl.formatMessage({id: 'boardPage.confirm-join-button', defaultMessage: 'Join'}),
destructive: true, //board.channelId !== '',
{!mobileWarningClosed &&
<div className='mobileWarning'>
<div>
<FormattedMessage
id='Error.mobileweb'
defaultMessage='Mobile web support is currently in early beta. Not all functionality may be present.'
onConfirm: onConfirmJoin,
onClose: () => {
setShowJoinBoardDialog(false)
history.goBack()
},
}}
/>}
{!showJoinBoardDialog &&
<div className='BoardPage'>
{!props.new && <TeamToBoardAndViewRedirect/>}
<BackwardCompatibilityQueryParamsRedirect/>
<SetWindowTitleAndIcon/>
<UndoRedoHotKeys/>
<WebsocketConnection/>
<CloudMessage/>
<VersionMessage/>
{!mobileWarningClosed &&
<div className='mobileWarning'>
<div>
<FormattedMessage
id='Error.mobileweb'
defaultMessage='Mobile web support is currently in early beta. Not all functionality may be present.'
/>
</div>
<IconButton
onClick={() => {
UserSettings.mobileWarningClosed = true
setMobileWarningClosed(true)
}}
icon={<CloseIcon/>}
title='Close'
className='margin-right'
/>
</div>}
{props.readonly && activeBoardId === undefined &&
<div className='error'>
{intl.formatMessage({id: 'BoardPage.syncFailed', defaultMessage: 'Board may be deleted or access revoked.'})}
</div>}
{
// Don't display Templates page
// if readonly mode and no board defined.
(!props.readonly || activeBoardId !== undefined) &&
<Workspace
readonly={props.readonly || false}
/>
</div>
<IconButton
onClick={() => {
UserSettings.mobileWarningClosed = true
setMobileWarningClosed(true)
}}
icon={<CloseIcon/>}
title='Close'
className='margin-right'
/>
</div>}
{props.readonly && activeBoardId === undefined &&
<div className='error'>
{intl.formatMessage({id: 'BoardPage.syncFailed', defaultMessage: 'Board may be deleted or access revoked.'})}
</div>}
{
// Don't display Templates page
// if readonly mode and no board defined.
(!props.readonly || activeBoardId !== undefined) &&
<Workspace
readonly={props.readonly || false}
/>
}
</div>
}
</div>
</>
)
}

View File

@ -302,7 +302,7 @@ describe('properties/dateRange', () => {
// About `Date()`
// > "When called as a function, returns a string representation of the current date and time"
const date = new Date()
const today = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
const today = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12)
const {getByText, getByTitle} = render(component)
const dayDisplay = getByText('Empty')

View File

@ -97,6 +97,7 @@ function DateRange(props: PropertyProps): JSX.Element {
const handleDayClick = (day: Date) => {
const range: DateProperty = {}
day.setHours(12)
if (isRange) {
const newRange = DateUtils.addDayToRange(day, {from: dateFrom, to: dateTo})
range.from = newRange.from?.getTime()

View File

@ -36,7 +36,7 @@ export const fetchBoardMembers = createAsyncThunk(
const users = [] as IUser[]
const userIDs = members.map((member) => member.userId)
const usersData = await client.getUsersList(userIDs)
const usersData = await client.getTeamUsersList(userIDs, teamId)
users.push(...usersData)
thunkAPI.dispatch(setBoardUsers(users))
@ -85,9 +85,13 @@ export const updateMembersEnsuringBoardsAndUsers = createAsyncThunk(
if (boardUsers[m.userId]) {
return
}
const user = await client.getUser(m.userId)
if (user) {
thunkAPI.dispatch(addBoardUsers([user]))
const board = await client.getBoard(m.boardId)
if (board) {
const user = await client.getTeamUsersList([m.userId], board.teamId)
if (user) {
thunkAPI.dispatch(addBoardUsers(user))
}
}
})

View File

@ -14,6 +14,7 @@ interface IUser {
update_at: number
is_bot: boolean
is_guest: boolean
permissions?: string[]
roles: string
}

View File

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`widgets/adminBadge should match the snapshot for Admin 1`] = `
<div>
<div
class="AdminBadge"
>
<div
class="AdminBadge__box"
>
Admin
</div>
</div>
</div>
`;
exports[`widgets/adminBadge should match the snapshot for TeamAdmin 1`] = `
<div>
<div
class="AdminBadge"
>
<div
class="AdminBadge__box"
>
Team Admin
</div>
</div>
</div>
`;

View File

@ -0,0 +1,16 @@
.AdminBadge {
display: inline-flex;
align-items: center;
margin: 0 10px 0 4px;
}
.AdminBadge__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;
}

View 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 {render} from '@testing-library/react'
import '@testing-library/jest-dom'
import {wrapIntl} from '../../testUtils'
import AdminBadge from './adminBadge'
describe('widgets/adminBadge', () => {
test('should match the snapshot for TeamAdmin', () => {
const {container} = render(wrapIntl(<AdminBadge permissions={['manage_team']}/>))
expect(container).toMatchSnapshot()
})
test('should match the snapshot for Admin', () => {
const {container} = render(wrapIntl(<AdminBadge permissions={['manage_team', 'manage_system']}/>))
expect(container).toMatchSnapshot()
})
test('should match the snapshot for empty', () => {
const {container} = render(wrapIntl(<AdminBadge permissions={[]}/>))
expect(container).toMatchInlineSnapshot('<div />')
})
test('should match the snapshot for invalid permission', () => {
const {container} = render(wrapIntl(<AdminBadge permissions={['invalid_permission']}/>))
expect(container).toMatchInlineSnapshot('<div />')
})
})

View File

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react'
import {useIntl} from 'react-intl'
import './adminBadge.scss'
type Props = {
permissions?: string[]
}
const AdminBadge = (props: Props) => {
const intl = useIntl()
if (!props.permissions) {
return null
}
let text = ''
if (props.permissions?.find((s) => s === 'manage_system')) {
text = intl.formatMessage({id: 'AdminBadge.SystemAdmin', defaultMessage: 'Admin'})
} else if (props.permissions?.find((s) => s === 'manage_team')) {
text = intl.formatMessage({id: 'AdminBadge.TeamAdmin', defaultMessage: 'Team Admin'})
} else {
return null
}
return (
<div className='AdminBadge'>
<div className='AdminBadge__box'>
{text}
</div>
</div>
)
}
export default memo(AdminBadge)

View File

@ -7,6 +7,8 @@ import {Picker, BaseEmoji} from 'emoji-mart'
import './emojiPicker.scss'
import emojiSpirit from '../../static/emoji_spirit.png'
type Props = {
onSelect: (emoji: string) => void
}
@ -18,6 +20,7 @@ const EmojiPicker: FC<Props> = (props: Props): JSX.Element => (
>
<Picker
onSelect={(emoji: BaseEmoji) => props.onSelect(emoji.native)}
backgroundImageFn={() => emojiSpirit}
/>
</div>
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB