Skip to content
Merged
3 changes: 3 additions & 0 deletions webapp/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@
"WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself",
"WelcomePage.StartUsingIt.Text": "Start using it",
"Workspace.editing-board-template": "You're editing a board template.",
"accessDenied.back-to-home": "Back to your boards",
"accessDenied.page.title": "You don’t have access to this board",
"accessDenied.page.subtitle": "This board is private or has restricted permissions.",
"badge.guest": "Guest",
"boardPage.confirm-join-button": "Join",
"boardPage.confirm-join-text": "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?",
Expand Down
1 change: 1 addition & 0 deletions webapp/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum ErrorId {
NotLoggedIn = 'not-logged-in',
InvalidReadOnlyBoard = 'invalid-read-only-board',
BoardNotFound = 'board-not-found',
AccessDenied = 'access-denied',
}

type ErrorDef = {
Expand Down
6 changes: 6 additions & 0 deletions webapp/src/octoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,12 @@ class OctoClient {
method: 'POST',
}))

if (response.status === 403) {
const error = new Error('access-denied') as Error & {status: number}
error.status = 403
throw error
}

if (response.status !== 200) {
return undefined
}
Expand Down
40 changes: 40 additions & 0 deletions webapp/src/pages/accessDeniedPage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.AccessDeniedPage {
height: 100%;
display: flex;
align-items: center;
justify-content: center;

> div {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
text-align: center;
}

.title {
color: var(--center-channel-color, #3F4350);
text-align: center;
font-family: Metropolis;
font-size: 28px;
font-style: normal;
font-weight: 600;
line-height: 36px;
margin-bottom: 8px;
}

.subtitle {
color: rgba(var(--center-channel-color-rgb), 0.75);
text-align: center;
font-family: "Open Sans";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
max-width: 600px;
}

svg {
margin: 24px 0;
}
}
88 changes: 88 additions & 0 deletions webapp/src/pages/accessDeniedPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React, {useCallback} from 'react'
import {useHistory, useLocation} from 'react-router-dom'
import {FormattedMessage, useIntl} from 'react-intl'

import Button from '../widgets/buttons/button'
import './accessDeniedPage.scss'

import {setGlobalError} from '../store/globalError'
import {useAppDispatch} from '../store/hooks'
import {UserSettings} from '../userSettings'
import AccessDeniedIllustration from '../svg/access-denied-illustation'

const clearLastBoardAndViewIds = () => {
const lastTeamId = UserSettings.lastTeamId
if (lastTeamId) {
const lastBoardId = UserSettings.lastBoardId[lastTeamId]
if (lastBoardId) {
UserSettings.setLastViewId(lastBoardId, null)
}
UserSettings.setLastBoardID(lastTeamId, null)
}
}

const AccessDeniedPage = () => {
const history = useHistory()
const location = useLocation()
const intl = useIntl()
const dispatch = useAppDispatch()

const handleBackToHome = useCallback(() => {
// Clear any error state before navigating
dispatch(setGlobalError(''))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we, as precaution, clear out the last board and view IDs here? What happens if local storage contains last board/view ID of a board user doesn't have access to. I know we are clearing those before sending the user to this page, but maybe just to be extra careful, we could clear them here as well.


// Clear last board/view IDs as a precaution to prevent redirect loops.
const referrerPath = new URLSearchParams(location.search).get('r')
if (referrerPath) {
try {
const pathParts = decodeURIComponent(referrerPath).split('/').filter(Boolean)
if (pathParts[0] === 'team' && pathParts.length >= 3) {
const teamId = pathParts[1]
const boardId = pathParts[2]
UserSettings.setLastBoardID(teamId, null)
UserSettings.setLastViewId(boardId, null)
}
} catch {
clearLastBoardAndViewIds()
}
} else {
clearLastBoardAndViewIds()
}

// Always navigate to home, not back to the board that caused the error
history.push('/')
}, [history, dispatch, location.search])

return (
<div className='AccessDeniedPage'>
<div>
<AccessDeniedIllustration/>
<div className='title'>
<FormattedMessage
id='accessDenied.page.title'
defaultMessage={'You don’t have access to this board'}
/>
</div>
<div className='subtitle'>
<FormattedMessage
id='accessDenied.page.subtitle'
defaultMessage={'This board is private or has restricted permissions.'}
/>
</div>
<br/>
<Button
filled={true}
size='large'
onClick={handleBackToHome}
>
{intl.formatMessage({id: 'accessDenied.back-to-home', defaultMessage: 'Back to your boards'})}
</Button>
</div>
</div>
)
}

export default React.memo(AccessDeniedPage)
89 changes: 69 additions & 20 deletions webapp/src/pages/boardPage/boardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {Card} from '../../blocks/card'
import {
updateBoards,
updateMembersEnsuringBoardsAndUsers,
getCurrentBoard,
getCurrentBoardId,
setCurrent as setCurrentBoard,
fetchBoardMembers,
Expand All @@ -43,7 +44,8 @@ import {
followBlock,
unfollowBlock,
} from '../../store/users'
import {setGlobalError} from '../../store/globalError'
import {setGlobalError, getGlobalError} from '../../store/globalError'
import {ErrorId} from '../../errors'
import {UserSettings} from '../../userSettings'

import IconButton from '../../widgets/buttons/iconButton'
Expand Down Expand Up @@ -72,6 +74,7 @@ const BoardPage = (props: Props): JSX.Element => {
const intl = useIntl()
const activeBoardId = useAppSelector(getCurrentBoardId)
const activeViewId = useAppSelector(getCurrentViewId)
const currentBoard = useAppSelector(getCurrentBoard)
const dispatch = useAppDispatch()
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, teamId?: string}>()
const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed)
Expand All @@ -82,6 +85,7 @@ const BoardPage = (props: Props): JSX.Element => {
const category = useAppSelector(getCategoryOfBoard(activeBoardId))
const [showJoinBoardDialog, setShowJoinBoardDialog] = useState<boolean>(false)
const history = useHistory()
const globalError = useAppSelector<string>(getGlobalError)

// if we're in a legacy route and not showing a shared board,
// redirect to the new URL schema equivalent
Expand Down Expand Up @@ -194,29 +198,65 @@ const BoardPage = (props: Props): JSX.Element => {
}

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)
try {
const member = await octoClient.joinBoard(boardId, allowAdmin)
if (!member) {
if (myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) {
setShowJoinBoardDialog(true)
return
}
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError('board-not-found'))
return
}
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 result: any = await dispatch(loadBoardData(boardId))
if (result.type === loadBoardData.rejected.type) {
return
}
if (result.payload?.blocks?.length > 0 && myUser.id) {
UserSettings.setLastBoardID(boardTeamId, boardId)
}
} catch (error: unknown) {
const err = error as {status?: number; message?: string}
if (err?.status === 403 || err?.message === 'access-denied') {
// Check if user is admin before redirecting to access denied page
const isAdmin = myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')
if (isAdmin) {
setShowJoinBoardDialog(true)
return
}
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError(ErrorId.AccessDenied))
} else {
dispatch(setGlobalError('board-not-found'))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

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)
if (result.type === loadBoardData.rejected.type) {
if (result.error?.message === ErrorId.AccessDenied) {
const isAdmin = myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')
if (isAdmin) {
setShowJoinBoardDialog(true)
return
}
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError(ErrorId.AccessDenied))
}
return
}
if (result.payload?.blocks?.length === 0 && myUser.id) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
await joinBoard(myUser, boardTeamId, boardId, false)
} catch (error: unknown) {
// Error already handled in joinBoard
}
} else {
// set board as most recently viewed board
UserSettings.setLastBoardID(boardTeamId, boardId)
Expand Down Expand Up @@ -252,6 +292,14 @@ const BoardPage = (props: Props): JSX.Element => {
}
}, [teamId, match.params.boardId, me?.id])

// When the board is removed from the store while viewing (e.g. user was removed via websocket),
// re-verify access so we show access-denied instead of the template picker
useEffect(() => {
if (match.params.boardId && !props.readonly && me && !currentBoard) {
loadOrJoinBoard(me, teamId, match.params.boardId)
}
}, [teamId, match.params.boardId, me?.id, currentBoard, loadOrJoinBoard])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const handleUnhideBoard = async (boardID: string) => {
if (!me || !category) {
return
Expand All @@ -278,6 +326,11 @@ const BoardPage = (props: Props): JSX.Element => {
}, [activeBoardId, activeViewId])
}

// Don't render board if access is denied - GlobalErrorRedirect will handle the redirect
if (globalError === ErrorId.AccessDenied || globalError === ErrorId.InvalidReadOnlyBoard) {
return <></>
}

return (
<>
{showJoinBoardDialog &&
Expand Down Expand Up @@ -327,10 +380,6 @@ const BoardPage = (props: Props): JSX.Element => {
/>
</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
Expand Down
22 changes: 20 additions & 2 deletions webapp/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import {IAppWindow} from './types'
import BoardPage from './pages/boardPage/boardPage'
import WelcomePage from './pages/welcome/welcomePage'
import ErrorPage from './pages/errorPage'
import AccessDeniedPage from './pages/accessDeniedPage'
import {Utils} from './utils'
import octoClient from './octoClient'
import {setGlobalError, getGlobalError} from './store/globalError'
import {useAppSelector, useAppDispatch} from './store/hooks'
import {ErrorId} from './errors'
import {getFirstTeam, fetchTeams, Team} from './store/teams'
import {getSidebarCategories, CategoryBoards} from './store/sidebar'
import {getMySortedBoards} from './store/boards'
Expand Down Expand Up @@ -173,13 +175,26 @@ function GlobalErrorRedirect() {
const globalError = useAppSelector<string>(getGlobalError)
const dispatch = useAppDispatch()
const history = useHistory()
const location = useLocation()

useEffect(() => {
if (globalError) {
// Don't redirect if we're already on an error page
if (location.pathname === '/error' || location.pathname === '/access-denied') {
dispatch(setGlobalError(''))
return
}

dispatch(setGlobalError(''))
history.replace(`/error?id=${globalError}`)
// Redirect to access denied page for access denied errors
if (globalError === ErrorId.AccessDenied || globalError === ErrorId.InvalidReadOnlyBoard) {
const currentPath = location.pathname + location.search
history.replace(`/access-denied?r=${encodeURIComponent(currentPath)}`)
} else {
history.replace(`/error?id=${globalError}`)
}
}
}, [globalError, history, dispatch])
}, [globalError, history, location, dispatch])

return null
}
Expand Down Expand Up @@ -223,6 +238,9 @@ const FocalboardRouter = (props: Props): JSX.Element => {
<FBRoute path='/error'>
<ErrorPage/>
</FBRoute>
<FBRoute path='/access-denied'>
<AccessDeniedPage/>
</FBRoute>
<FBRoute path={['/team/:teamId/new/:channelId']}>
<BoardPage new={true}/>
</FBRoute>
Expand Down
5 changes: 4 additions & 1 deletion webapp/src/store/globalError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import {createSlice, PayloadAction} from '@reduxjs/toolkit'

import {initialLoad, initialReadOnlyLoad} from './initialLoad'
import {initialLoad, initialReadOnlyLoad, loadBoardData} from './initialLoad'

import {RootState} from './index'

Expand All @@ -23,6 +23,9 @@ const globalErrorSlice = createSlice({
builder.addCase(initialLoad.rejected, (state, action) => {
state.value = action.error.message || ''
})
builder.addCase(loadBoardData.rejected, (state, action) => {
state.value = action.error.message || ''
})
},
})

Expand Down
Loading
Loading