mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
feat(frontend): add permission denied handling for person pages
This commit is contained in:
@@ -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
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user