diff --git a/server/boards/boardsapp_util.go b/server/boards/boardsapp_util.go index ba22451c4..6f9aab86f 100644 --- a/server/boards/boardsapp_util.go +++ b/server/boards/boardsapp_util.go @@ -80,7 +80,7 @@ func createBoardsConfig(mmconfig mm_model.Config, baseURL string, serverID strin showFullName = *mmconfig.PrivacySettings.ShowFullName } - serverRoot := baseURL + "/plugins/focalboard" + serverRoot := baseURL + "/boards" return &config.Configuration{ ServerRoot: serverRoot, diff --git a/webapp/src/components/sidebar/sidebarCategory.tsx b/webapp/src/components/sidebar/sidebarCategory.tsx index 0c4c7f50b..43c35c72f 100644 --- a/webapp/src/components/sidebar/sidebarCategory.tsx +++ b/webapp/src/components/sidebar/sidebarCategory.tsx @@ -39,6 +39,7 @@ import {TOUR_SIDEBAR, SidebarTourSteps, TOUR_BOARD, FINISHED} from '../../compon import telemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient' import {getCurrentTeam} from '../../store/teams' +import {UserSettings} from '../../userSettings' import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../confirmationDialogBox' @@ -183,6 +184,10 @@ const SidebarCategory = (props: Props) => { // Capture the category ID before deletion const deletedFromCategoryID = props.categoryBoards.id + // Clear localStorage entries for the deleted board + UserSettings.setLastBoardID(teamID, null) + UserSettings.setLastViewId(deleteBoard.id, null) + mutator.deleteBoard( deleteBoard, intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete board'}), diff --git a/webapp/src/error_boundary.tsx b/webapp/src/error_boundary.tsx index f10dfc7e2..13fc61b7d 100644 --- a/webapp/src/error_boundary.tsx +++ b/webapp/src/error_boundary.tsx @@ -19,7 +19,7 @@ export default class ErrorBoundary extends React.Component { msg = 'Redirecting to error page...' handleError = (): void => { - const url = Utils.getBaseURL() + '/error?id=unknown' + const url = Utils.getFrontendBaseURL(true) + '/error?id=unknown' Utils.log('error boundary redirecting to ' + url) window.location.replace(url) } diff --git a/webapp/src/errors.ts b/webapp/src/errors.ts index c867eccb8..16b5b4f28 100644 --- a/webapp/src/errors.ts +++ b/webapp/src/errors.ts @@ -11,6 +11,7 @@ enum ErrorId { NotLoggedIn = 'not-logged-in', InvalidReadOnlyBoard = 'invalid-read-only-board', BoardNotFound = 'board-not-found', + ViewNotFound = 'view-not-found', } type ErrorDef = { @@ -66,6 +67,14 @@ function errorDefFromId(id: ErrorId | null): ErrorDef { errDef.button1Fill = true break } + case ErrorId.ViewNotFound: { + errDef.title = intl.formatMessage({id: 'error.view-not-found', defaultMessage: 'View not found.'}) + errDef.button1Enabled = true + errDef.button1Text = intl.formatMessage({id: 'error.back-to-board', defaultMessage: 'Back to board'}) + errDef.button1Redirect = '/' + errDef.button1Fill = true + break + } case ErrorId.NotLoggedIn: { errDef.title = intl.formatMessage({id: 'error.not-logged-in', defaultMessage: 'Your session may have expired or you\'re not logged in. Log in again to access Boards.'}) errDef.button1Enabled = true diff --git a/webapp/src/pages/boardPage/boardPage.tsx b/webapp/src/pages/boardPage/boardPage.tsx index ba2c8b17c..5f4ffb257 100644 --- a/webapp/src/pages/boardPage/boardPage.tsx +++ b/webapp/src/pages/boardPage/boardPage.tsx @@ -27,12 +27,13 @@ import { setCurrent as setCurrentBoard, fetchBoardMembers, addMyBoardMemberships, + getBoards, } from '../../store/boards' -import {getCurrentViewId, setCurrent as setCurrentView, updateViews} from '../../store/views' +import {getCurrentViewId, getCurrentBoardViews, 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' +import {getAllTeams, setTeam} from '../../store/teams' import {updateCards} from '../../store/cards' import {updateComments} from '../../store/comments' import {updateAttachments} from '../../store/attachments' @@ -82,6 +83,10 @@ const BoardPage = (props: Props): JSX.Element => { const category = useAppSelector(getCategoryOfBoard(activeBoardId)) const [showJoinBoardDialog, setShowJoinBoardDialog] = useState(false) const history = useHistory() + const allTeams = useAppSelector(getAllTeams) + const allBoards = useAppSelector(getBoards) + const boardViews = useAppSelector(getCurrentBoardViews) + const [loadedBoardId, setLoadedBoardId] = useState('') // if we're in a legacy route and not showing a shared board, // redirect to the new URL schema equivalent @@ -102,10 +107,18 @@ const BoardPage = (props: Props): JSX.Element => { // TODO: Make this less brittle. This only works because this is the root render function useEffect(() => { - UserSettings.lastTeamId = teamId - octoClient.teamId = teamId - dispatch(setTeam(teamId)) - }, [teamId]) + if (allTeams.length === 0) { + return + } + const isValidTeam = allTeams.some((team) => team.id === match.params.teamId) || match.params.teamId === Constants.globalTeamId + if (isValidTeam) { + UserSettings.lastTeamId = teamId + octoClient.teamId = teamId + dispatch(setTeam(teamId)) + } else { + dispatch(setGlobalError('team-undefined')) + } + }, [teamId, allTeams]) const loadAction: (boardId: string) => any = useMemo(() => { if (props.readonly) { @@ -194,6 +207,21 @@ const BoardPage = (props: Props): JSX.Element => { } const joinBoard = async (myUser: IUser, boardTeamId: string, boardId: string, allowAdmin: boolean) => { + const boardExists = await octoClient.getBoard(boardId) + if (!boardExists) { + UserSettings.setLastBoardID(boardTeamId, null) + UserSettings.setLastViewId(boardId, null) + dispatch(setGlobalError('board-not-found')) + return + } + + if (boardExists.teamId !== boardTeamId && boardExists.teamId !== Constants.globalTeamId) { + UserSettings.setLastBoardID(boardTeamId, null) + UserSettings.setLastViewId(boardId, null) + dispatch(setGlobalError('board-not-found')) + return + } + const member = await octoClient.joinBoard(boardId, allowAdmin) if (!member) { if (myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) { @@ -229,28 +257,62 @@ const BoardPage = (props: Props): JSX.Element => { }, []) useEffect(() => { - dispatch(loadAction(match.params.boardId)) + if (!match.params.boardId) { + return + } + dispatch(setCurrentBoard(match.params.boardId)) - if (match.params.boardId) { - // set the active board - dispatch(setCurrentBoard(match.params.boardId)) - - if (viewId !== Constants.globalTeamId) { - // reset current, even if empty string - dispatch(setCurrentView(viewId)) - if (viewId) { - // don't reset per board if empty string - UserSettings.setLastViewId(match.params.boardId, viewId) - } + if (viewId !== Constants.globalTeamId) { + dispatch(setCurrentView(viewId)) + if (viewId) { + UserSettings.setLastViewId(match.params.boardId, viewId) } } - }, [teamId, match.params.boardId, viewId, me?.id]) + dispatch(loadAction(match.params.boardId)) + }, [teamId, match.params.boardId, viewId, me?.id, dispatch, loadAction]) + + + useEffect(() => { + setLoadedBoardId('') + }, [teamId]) + + useEffect(() => { + if (!match.params.boardId || !me || props.readonly) { + return + } + + if (loadedBoardId === match.params.boardId) { + return + } + + const boardsLoaded = Object.keys(allBoards).length > 0 + if (!boardsLoaded) { + return + } + + const board = allBoards[match.params.boardId] + if (board && board.teamId !== teamId && board.teamId !== Constants.globalTeamId) { + dispatch(setGlobalError('board-not-found')) + return + } + + setLoadedBoardId(match.params.boardId) + loadOrJoinBoard(me, teamId, match.params.boardId) + }, [teamId, match.params.boardId, me, props.readonly, allBoards, loadOrJoinBoard, dispatch, loadedBoardId]) + + // Validate that the viewId exists in the board's views useEffect(() => { - if (match.params.boardId && !props.readonly && me) { - loadOrJoinBoard(me, teamId, match.params.boardId) + if (!match.params.viewId || props.readonly || props.new || !activeBoardId || boardViews.length === 0) { + return + } + + const viewExists = boardViews.some((view) => view.id === match.params.viewId) + if (!viewExists) { + UserSettings.setLastViewId(activeBoardId, null) + dispatch(setGlobalError('view-not-found')) } - }, [teamId, match.params.boardId, me?.id]) + }, [match.params.viewId, activeBoardId, boardViews, props.readonly, props.new]) const handleUnhideBoard = async (boardID: string) => { if (!me || !category) { diff --git a/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx b/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx index 5f9e5203f..6f3bcca4e 100644 --- a/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx +++ b/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect} from 'react' +import {useEffect, useMemo} from 'react' import {generatePath, useHistory, useRouteMatch} from 'react-router-dom' import {getBoards, getCurrentBoardId} from '../../store/boards' @@ -22,66 +22,80 @@ const TeamToBoardAndViewRedirect = (): null => { const boards = useAppSelector(getBoards) const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId + const boardCount = useMemo(() => Object.keys(boards).length, [boards]) + const categoryCount = useMemo(() => categories.length, [categories]) + useEffect(() => { - let boardID = match.params.boardId - if (!match.params.boardId) { - // first preference is for last visited board - boardID = UserSettings.lastBoardId[teamId] - - // if last visited board is unavailable, use the first board in categories list - if (!boardID && categories.length > 0) { - let goToBoardID: string | null = null - - for (const category of categories) { - for (const boardMetadata of category.boardMetadata) { - // pick the first category board that exists and is not hidden - if (!boardMetadata.hidden && boards[boardMetadata.boardID]) { - goToBoardID = boardMetadata.boardID - break - } + if (match.params.boardId) { + return + } + + if (boardCount === 0 && categoryCount === 0) { + return + } + + let boardID: string | undefined = undefined + + const lastBoardId = UserSettings.lastBoardId[teamId] + if (lastBoardId) { + const board = boards[lastBoardId] + if (board && (board.teamId === teamId || board.teamId === Constants.globalTeamId)) { + boardID = lastBoardId + } else { + UserSettings.setLastBoardID(teamId, null) + } + } + + if (!boardID && categoryCount > 0) { + for (const category of categories) { + for (const boardMetadata of category.boardMetadata) { + const board = boards[boardMetadata.boardID] + // Pick the first category board that exists, is not hidden, and belongs to this team + if (!boardMetadata.hidden && board && (board.teamId === teamId || board.teamId === Constants.globalTeamId)) { + boardID = boardMetadata.boardID + break } } - - // there may even be no boards at all - if (goToBoardID) { - boardID = goToBoardID + if (boardID) { + break } } + } - if (boardID) { - const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, boardId: boardID, viewID: undefined}) - history.replace(newPath) - - // return from here because the loadBoardData() call - // will fetch the data to be used below. We'll - // use it in the next render cycle. - return - } + if (boardID) { + const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, boardId: boardID, viewID: undefined}) + history.replace(newPath) + return } - let viewID = match.params.viewId + }, [teamId, match.params.boardId, boardCount, categoryCount, boards, categories, history, match.path, match.params]) + + const viewCount = useMemo(() => boardViews.length, [boardViews]) + + useEffect(() => { + const viewID = match.params.viewId // when a view isn't open, // but the data is available, try opening a view - if ((!viewID || viewID === '0') && boardId && boardId === match.params.boardId && boardViews && boardViews.length > 0) { + if ((!viewID || viewID === '0') && boardId && boardId === match.params.boardId && viewCount > 0) { // most recent view gets the first preference - viewID = UserSettings.lastViewId[boardID] - if (viewID) { - UserSettings.setLastViewId(boardID, viewID) - dispatch(setCurrentView(viewID)) + let selectedViewID = UserSettings.lastViewId[boardId] + if (selectedViewID) { + UserSettings.setLastViewId(boardId, selectedViewID) + dispatch(setCurrentView(selectedViewID)) } else if (boardViews.length > 0) { // if most recent view is unavailable, pick the first view - viewID = boardViews[0].id - UserSettings.setLastViewId(boardID, viewID) - dispatch(setCurrentView(viewID)) + selectedViewID = boardViews[0].id + UserSettings.setLastViewId(boardId, selectedViewID) + dispatch(setCurrentView(selectedViewID)) } - if (viewID) { - const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: viewID}) + if (selectedViewID) { + const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: selectedViewID}) history.replace(newPath) } } - }, [teamId, match.params.boardId, match.params.viewId, categories.length, boardViews.length, boardId]) + }, [match.params.boardId, match.params.viewId, viewCount, boardId, boardViews, dispatch, history, match.path, match.params]) return null } diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index ae590fccc..80da3923c 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -20,6 +20,7 @@ import {IAppWindow} from './types' import {ChangeHandlerType, WSMessage} from './wsclient' import {BoardCategoryWebsocketData, Category} from './store/sidebar' import {UserSettings} from './userSettings' +import {Constants} from './constants' declare let window: IAppWindow @@ -575,7 +576,8 @@ class Utils { } static getFrontendBaseURL(absolute?: boolean): string { - let frontendBaseURL = window.frontendBaseURL || Utils.getBaseURL() + // Always use /boards as the frontend base URL, never fall back to baseURL + let frontendBaseURL = window.frontendBaseURL ? window.frontendBaseURL : '/boards' frontendBaseURL = frontendBaseURL.replace(/\/+$/, '') if (frontendBaseURL.indexOf('/') === 0) { frontendBaseURL = frontendBaseURL.slice(1) @@ -785,6 +787,10 @@ class Utils { match: routerMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>, history: History, ) { + // Set the board as last viewed board in localStorage + const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId + UserSettings.setLastBoardID(teamId, boardId) + // if the same board, reuse the match params // otherwise remove viewId and cardId, results in first view being selected const params = {...match.params, boardId: boardId || ''}