feat(frontend): add permission denied handling for person pages

This commit is contained in:
Kari Lintulaakso
2025-11-13 14:10:56 +02:00
parent b5152214e4
commit 88d03fad95
5 changed files with 249 additions and 4 deletions
@@ -0,0 +1,61 @@
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'
import { Box, Button, Paper, Stack, Typography } from '@mui/material'
import { Link as RouterLink } from 'react-router-dom'
export type PermissionDeniedProps = {
title?: string
message?: string
actionHref?: string
actionLabel?: string
}
const defaultTitle = 'Permission denied'
const defaultMessage = 'You do not have permission to view this content.'
const defaultActionLabel = 'Go back home'
export const PermissionDenied = ({
title = defaultTitle,
message = defaultMessage,
actionHref = '/',
actionLabel = defaultActionLabel,
}: PermissionDeniedProps) => {
return (
<Paper elevation={4} sx={{ maxWidth: 480, margin: 'auto', mt: 6, p: 5 }} role="alert">
<Stack spacing={3} alignItems="center">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'warning.light',
color: 'warning.dark',
borderRadius: '50%',
height: 72,
width: 72,
}}
>
<LockOutlinedIcon sx={{ fontSize: 36 }} />
</Box>
<Stack spacing={1} alignItems="center" textAlign="center">
<Typography component="h2" variant="h5">
{title}
</Typography>
<Typography color="text.secondary">{message}</Typography>
</Stack>
{actionHref && (
<Button
component={RouterLink}
to={actionHref}
variant="contained"
color="warning"
aria-label="Navigate back to the home page"
>
{actionLabel}
</Button>
)}
</Stack>
</Paper>
)
}
export default PermissionDenied
+4 -4
View File
@@ -21,8 +21,8 @@ import { TimeBoundTable } from './TimeBound/TimeBoundTable'
import { TimeUnitTable } from './TimeUnit/TimeUnitTable'
import { TimeUnitDetails } from './TimeUnit/TimeUnitDetails'
import { ReferenceDetails } from './Reference/ReferenceDetails'
import { PersonTable } from './Person/PersonTable'
import { PersonDetails } from './Person/PersonDetails'
import { PersonListPage } from '@/pages/PersonListPage'
import { PersonDetailPage } from '@/pages/PersonDetailPage'
import { ProjectTable } from './Project/ProjectTable'
import { ProjectDetails } from './Project/ProjectDetails'
import { RegionTable } from './Region/RegionTable'
@@ -168,8 +168,8 @@ export const personPage = (
// allowedRoles, as the component has to be rendered for the user's own user-page.
// This is ok because the requests should fail on backend anyways.
<Page
tableView={<PersonTable />}
detailView={<PersonDetails />}
tableView={<PersonListPage />}
detailView={<PersonDetailPage />}
viewName="person"
idFieldName="initials"
createTitle={(person: PersonDetailsType) => `${person.surname}`}
+39
View File
@@ -0,0 +1,39 @@
import { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { skipToken } from '@reduxjs/toolkit/query'
import { PermissionDenied } from '@/components/PermissionDenied'
import { PersonDetails } from '@/components/Person/PersonDetails'
import { resolveErrorStatus } from '@/components/TableView/errorUtils'
import { useUser } from '@/hooks/user'
import { useGetPersonDetailsQuery } from '@/redux/personReducer'
export const PersonDetailPage = () => {
const { id: idFromUrl } = useParams()
const user = useUser()
const isUserPage = idFromUrl === 'user-page'
const isNew = idFromUrl === 'new'
const detailId = isUserPage ? user.initials : idFromUrl
const queryArg = !detailId || isNew ? skipToken : detailId
const { isError, error } = useGetPersonDetailsQuery(queryArg)
const isForbidden = useMemo(() => {
if (!isError) return false
const status = resolveErrorStatus(error)
return status === 403
}, [error, isError])
if (isForbidden) {
return (
<PermissionDenied
title="You do not have access to this person"
message="Your account cannot view the requested person record."
/>
)
}
return <PersonDetails />
}
export default PersonDetailPage
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import { PermissionDenied } from '@/components/PermissionDenied'
import { PersonTable } from '@/components/Person/PersonTable'
import { resolveErrorStatus } from '@/components/TableView/errorUtils'
import { useGetAllPersonsQuery } from '@/redux/personReducer'
export const PersonListPage = () => {
const { isError, error } = useGetAllPersonsQuery()
const isForbidden = useMemo(() => {
if (!isError) return false
const status = resolveErrorStatus(error)
return status === 403
}, [error, isError])
if (isForbidden) {
return (
<PermissionDenied
title="You do not have access to the people table"
message="Your account is not allowed to browse the people directory. Please contact an administrator if you believe this is a mistake."
/>
)
}
return <PersonTable />
}
export default PersonListPage
@@ -0,0 +1,117 @@
import { describe, expect, it, beforeEach, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { PermissionDenied } from '@/components/PermissionDenied'
import { PersonListPage } from '@/pages/PersonListPage'
import { PersonDetailPage } from '@/pages/PersonDetailPage'
jest.mock('@/components/Person/PersonTable', () => ({
PersonTable: () => <div data-testid="person-table">table</div>,
}))
jest.mock('@/components/Person/PersonDetails', () => ({
PersonDetails: () => <div data-testid="person-details">details</div>,
}))
const mockUseGetAllPersonsQuery = jest.fn()
const mockUseGetPersonDetailsQuery = jest.fn()
const mockUseUser = jest.fn()
jest.mock('@/redux/personReducer', () => ({
useGetAllPersonsQuery: (arg?: unknown) => mockUseGetAllPersonsQuery(arg),
useGetPersonDetailsQuery: (arg?: unknown) => mockUseGetPersonDetailsQuery(arg),
}))
jest.mock('@/hooks/user', () => ({
useUser: () => mockUseUser(),
}))
describe('permission denied component', () => {
it('renders provided message copy', () => {
render(
<MemoryRouter>
<PermissionDenied title="Blocked" message="Stop" actionHref={undefined} />
</MemoryRouter>
)
expect(screen.getByRole('alert')).toBeTruthy()
expect(screen.getByText('Blocked')).toBeTruthy()
expect(screen.getByText('Stop')).toBeTruthy()
})
})
describe('PersonListPage', () => {
beforeEach(() => {
mockUseGetAllPersonsQuery.mockReset()
})
it('shows the permission denied view when the list query returns 403', () => {
mockUseGetAllPersonsQuery.mockReturnValue({
isError: true,
error: { status: 403, data: {} } as FetchBaseQueryError,
})
render(
<MemoryRouter>
<PersonListPage />
</MemoryRouter>
)
expect(
screen.getByText('Your account is not allowed to browse the people directory. Please contact an administrator if you believe this is a mistake.')
).toBeTruthy()
expect(screen.queryByTestId('person-table')).toBeNull()
})
it('renders the table when no authorization error occurs', () => {
mockUseGetAllPersonsQuery.mockReturnValue({ isError: false })
render(
<MemoryRouter>
<PersonListPage />
</MemoryRouter>
)
expect(screen.getByTestId('person-table')).toBeTruthy()
})
})
describe('PersonDetailPage', () => {
beforeEach(() => {
mockUseGetPersonDetailsQuery.mockReset()
mockUseUser.mockReturnValue({ initials: 'JD' })
})
it('shows the permission denied view when details query returns 403', () => {
mockUseGetPersonDetailsQuery.mockReturnValue({
isError: true,
error: { status: 403, data: {} } as FetchBaseQueryError,
})
render(
<MemoryRouter initialEntries={['/person/JD']}>
<Routes>
<Route path="/person/:id" element={<PersonDetailPage />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByText('You do not have access to this person')).toBeTruthy()
expect(screen.queryByTestId('person-details')).toBeNull()
})
it('renders the person details when authorized', () => {
mockUseGetPersonDetailsQuery.mockReturnValue({ isError: false })
render(
<MemoryRouter initialEntries={['/person/JD']}>
<Routes>
<Route path="/person/:id" element={<PersonDetailPage />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByTestId('person-details')).toBeTruthy()
})
})