mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge pull request #974 from nowcommunity/codex/plan-error-handling-for-unauthorized-access
Align person authorization responses with consistent errors
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { PersonDetailsType } from '../../../../frontend/src/shared/types'
|
||||
import { pool } from '../../utils/db'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send, unauthenticatedError } from '../utils'
|
||||
import { editedPerson, existingPerson } from './data'
|
||||
|
||||
let updatedPerson: PersonDetailsType | null = null
|
||||
@@ -80,7 +80,7 @@ describe('Updating person works', () => {
|
||||
const { body: resultBodyNoPerm, status: resultStatusNoPerm } = await send('person/', 'PUT', {
|
||||
person: { initials: 'AD', first_name: 'Test first name 2' },
|
||||
})
|
||||
expect(resultBodyNoPerm).toEqual(noPermError)
|
||||
expect(resultBodyNoPerm).toEqual(unauthenticatedError)
|
||||
expect(resultStatusNoPerm).toEqual(401)
|
||||
|
||||
await login('testEr')
|
||||
@@ -88,14 +88,14 @@ describe('Updating person works', () => {
|
||||
person: { initials: 'AD', first_name: 'Test first name 2' },
|
||||
})
|
||||
expect(resultBodyEr).toEqual(noPermError)
|
||||
expect(resultStatusEr).toEqual(401)
|
||||
expect(resultStatusEr).toEqual(403)
|
||||
|
||||
await login('testEu')
|
||||
const { body: resultBodyEu, status: resultStatusEu } = await send('person/', 'PUT', {
|
||||
person: { initials: 'AD', first_name: 'Test first name 2' },
|
||||
})
|
||||
expect(resultBodyEu).toEqual(noPermError)
|
||||
expect(resultStatusEu).toEqual(401)
|
||||
expect(resultStatusEu).toEqual(403)
|
||||
})
|
||||
|
||||
it('Non-admin user updating their own information succeeds', async () => {
|
||||
|
||||
@@ -100,3 +100,4 @@ export const resetDatabase = async () => {
|
||||
export const resetDatabaseTimeout: number = 20000
|
||||
|
||||
export const noPermError = { message: 'User not authorized for the requested resource or action' }
|
||||
export const unauthenticatedError = { message: 'User is not logged in' }
|
||||
|
||||
@@ -12,6 +12,9 @@ import { requireOneOf } from '../middlewares/authorizer'
|
||||
import { writePerson } from '../services/write/person'
|
||||
import { writeUserGroup } from '../services/write/user'
|
||||
|
||||
const UNAUTHENTICATED_ERROR = { message: 'User is not logged in' }
|
||||
const FORBIDDEN_ERROR = { message: 'User not authorized for the requested resource or action' }
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/all', requireOneOf([Role.Admin]), async (_req, res) => {
|
||||
@@ -23,14 +26,12 @@ router.get('/:id', async (req, res) => {
|
||||
const id = req.params.id
|
||||
|
||||
/* Access checking happens differently for this route, since we want to allow the route to return the user's own data for any user */
|
||||
if (!req.user)
|
||||
return res.status(401).send({
|
||||
message: 'User is not logged in',
|
||||
})
|
||||
if (req.user.role !== Role.Admin && req.user.initials !== id)
|
||||
return res.status(401).send({
|
||||
message: 'User not authorized for the requested resource or action',
|
||||
})
|
||||
if (!req.user) {
|
||||
return res.status(401).send(UNAUTHENTICATED_ERROR)
|
||||
}
|
||||
if (req.user.role !== Role.Admin && req.user.initials !== id) {
|
||||
return res.status(403).send(FORBIDDEN_ERROR)
|
||||
}
|
||||
|
||||
const person = await getPersonDetails(id)
|
||||
if (!person) return res.status(404).send()
|
||||
@@ -45,14 +46,12 @@ router.put(
|
||||
return res.status(403).send({ error: 'Missing initials, creating new persons is not yet implemented' })
|
||||
}
|
||||
/* Access checking happens differently for this route, since we want to allow users to modify their own data */
|
||||
if (!req.user)
|
||||
return res.status(401).send({
|
||||
message: 'User not authorized for the requested resource or action',
|
||||
})
|
||||
if (req.user.role !== Role.Admin && req.user.initials !== editedPerson.initials)
|
||||
return res.status(401).send({
|
||||
message: 'User not authorized for the requested resource or action',
|
||||
})
|
||||
if (!req.user) {
|
||||
return res.status(401).send(UNAUTHENTICATED_ERROR)
|
||||
}
|
||||
if (req.user.role !== Role.Admin && req.user.initials !== editedPerson.initials) {
|
||||
return res.status(403).send(FORBIDDEN_ERROR)
|
||||
}
|
||||
|
||||
const validationErrors = validateEntirePerson({ ...editedPerson })
|
||||
if (validationErrors.length > 0) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export default {
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
|
||||
@@ -8,7 +8,12 @@ import { LocalitiesMap } from '../Map/LocalitiesMap'
|
||||
|
||||
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
|
||||
const { sqlLimit, sqlOffset, sqlColumnFilters, sqlOrderBy } = usePageContext()
|
||||
const { data: crossSearchQueryData, isFetching } = useGetAllCrossSearchQuery({
|
||||
const {
|
||||
data: crossSearchQueryData,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
} = useGetAllCrossSearchQuery({
|
||||
limit: sqlLimit,
|
||||
offset: sqlOffset,
|
||||
columnFilters: sqlColumnFilters,
|
||||
@@ -821,6 +826,8 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
|
||||
enableColumnFilterModes={true}
|
||||
serverSidePagination={true}
|
||||
isCrossSearchTable={true}
|
||||
isError={isError}
|
||||
error={error}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,12 @@ const decimalCount = (num: number) => {
|
||||
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
|
||||
const [selectedLocality, setSelectedLocality] = useState<string | undefined>()
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false)
|
||||
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
|
||||
const {
|
||||
data: localitiesQueryData,
|
||||
isFetching: localitiesQueryIsFetching,
|
||||
isError: localitiesQueryIsError,
|
||||
error: localitiesQueryError,
|
||||
} = useGetAllLocalitiesQuery()
|
||||
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
|
||||
const [filteredLocalities, setFilteredLocalities] = useState<SimplifiedLocality[]>()
|
||||
const columnFilters = usePageContext()
|
||||
@@ -466,6 +471,8 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
idFieldName="lid"
|
||||
columns={columns}
|
||||
isFetching={localitiesQueryIsFetching}
|
||||
isError={localitiesQueryIsError}
|
||||
error={localitiesQueryError}
|
||||
visibleColumns={visibleColumns}
|
||||
data={localitiesQueryData}
|
||||
url="locality"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TableView } from '../TableView/TableView'
|
||||
import { Museum } from '@/shared/types'
|
||||
|
||||
export const MuseumTable = ({ selectorFn }: { selectorFn?: (museum: Museum) => void }) => {
|
||||
const { data: museumQueryData, isFetching } = useGetAllMuseumsQuery()
|
||||
const { data: museumQueryData, isFetching, isError, error } = useGetAllMuseumsQuery()
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Museum>[]>(
|
||||
() => [
|
||||
@@ -69,6 +69,8 @@ export const MuseumTable = ({ selectorFn }: { selectorFn?: (museum: Museum) => v
|
||||
idFieldName="museum"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
visibleColumns={visibleColumns}
|
||||
data={museums}
|
||||
url="museum"
|
||||
|
||||
@@ -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,7 +4,7 @@ import { TableView } from '../TableView/TableView'
|
||||
import { personTableColumns } from '@/common'
|
||||
|
||||
export const PersonTable = ({ selectorFn }: { selectorFn?: (id: PersonDetailsType) => void }) => {
|
||||
const { data: personQueryData, isFetching } = useGetAllPersonsQuery()
|
||||
const { data: personQueryData, isFetching, isError, error } = useGetAllPersonsQuery()
|
||||
|
||||
const visibleColumns = {
|
||||
iniitals: false,
|
||||
@@ -17,6 +17,8 @@ export const PersonTable = ({ selectorFn }: { selectorFn?: (id: PersonDetailsTyp
|
||||
idFieldName="initials"
|
||||
columns={personTableColumns}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
visibleColumns={visibleColumns}
|
||||
data={personQueryData}
|
||||
url="person"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Project } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
|
||||
export const ProjectTable = ({ selectorFn }: { selectorFn?: (id: Project) => void }) => {
|
||||
const { data: projectQueryData, isFetching } = useGetAllProjectsQuery()
|
||||
const { data: projectQueryData, isFetching, isError, error } = useGetAllProjectsQuery()
|
||||
const columns = useMemo<MRT_ColumnDef<Project>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -47,6 +47,8 @@ export const ProjectTable = ({ selectorFn }: { selectorFn?: (id: Project) => voi
|
||||
idFieldName="pid"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
visibleColumns={visibleColumns}
|
||||
data={projectQueryData}
|
||||
url="project"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TableView } from '../TableView/TableView'
|
||||
import { referenceTableColumns } from '@/common'
|
||||
|
||||
export const ReferenceTable = ({ selectorFn }: { selectorFn?: (id: Reference) => void }) => {
|
||||
const { data: referenceQueryData, isFetching } = useGetAllReferencesQuery()
|
||||
const { data: referenceQueryData, isFetching, isError, error } = useGetAllReferencesQuery()
|
||||
let fixedNullDateData = undefined
|
||||
if (referenceQueryData) {
|
||||
// remove references with null date_primary values to not break the year filter,
|
||||
@@ -26,6 +26,8 @@ export const ReferenceTable = ({ selectorFn }: { selectorFn?: (id: Reference) =>
|
||||
data={fixedNullDateData}
|
||||
url="reference"
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Region } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
|
||||
export const RegionTable = ({ selectorFn }: { selectorFn?: (id: Region) => void }) => {
|
||||
const { data: reqionQueryData, isFetching } = useGetAllRegionsQuery()
|
||||
const { data: reqionQueryData, isFetching, isError, error } = useGetAllRegionsQuery()
|
||||
const columns = useMemo<MRT_ColumnDef<Region>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -33,6 +33,8 @@ export const RegionTable = ({ selectorFn }: { selectorFn?: (id: Region) => void
|
||||
idFieldName="reg_coord_id"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
visibleColumns={visibleColumns}
|
||||
data={reqionQueryData}
|
||||
url="region"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { type MRT_ColumnDef } from 'material-react-table'
|
||||
import { type SequenceQueryArgs, useGetSequencesQuery } from '@/redux/timeUnitReducer'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import type { SerializedError } from '@reduxjs/toolkit'
|
||||
import { Sequence } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
import { Table } from '@/components/Table/Table'
|
||||
@@ -25,12 +27,17 @@ export const SequenceTable = ({ selectorFn }: { selectorFn?: (id: Sequence) => v
|
||||
data: sequenceQueryData,
|
||||
isFetching,
|
||||
pagination,
|
||||
} = usePaginatedQuery<SequenceQueryArgs, Sequence>(useGetSequencesQuery, {
|
||||
tableId: TABLE_ID,
|
||||
queryArg,
|
||||
manualPageIndex: pageIndex,
|
||||
manualPageSize: pageSize,
|
||||
})
|
||||
isError,
|
||||
error,
|
||||
} = usePaginatedQuery<SequenceQueryArgs, Sequence, Sequence, FetchBaseQueryError | SerializedError>(
|
||||
useGetSequencesQuery,
|
||||
{
|
||||
tableId: TABLE_ID,
|
||||
queryArg,
|
||||
manualPageIndex: pageIndex,
|
||||
manualPageSize: pageSize,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagination) {
|
||||
@@ -83,6 +90,8 @@ export const SequenceTable = ({ selectorFn }: { selectorFn?: (id: Sequence) => v
|
||||
clickableRows={false}
|
||||
data={sequenceQueryData}
|
||||
url="sequence"
|
||||
isError={Boolean(isError)}
|
||||
error={error}
|
||||
/>
|
||||
</Table>
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => voi
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false)
|
||||
const [commentModalOpen, setCommentModalOpen] = useState<boolean>(false)
|
||||
const [commentModalData, setCommentModalData] = useState<CommentModalData | null>(null)
|
||||
const { data: speciesQueryData, isFetching } = useGetAllSpeciesQuery()
|
||||
const { data: speciesQueryData, isFetching, isError, error } = useGetAllSpeciesQuery()
|
||||
|
||||
const synonymFilterFns = useMemo<Record<string, MRT_FilterFn<Species>>>(
|
||||
() => ({
|
||||
@@ -496,6 +496,8 @@ export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => voi
|
||||
idFieldName="species_id"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
visibleColumns={visibleColumns}
|
||||
data={speciesQueryData}
|
||||
url="species"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
type MRT_ColumnFiltersState,
|
||||
type MRT_ColumnDef,
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
MRT_Row,
|
||||
type MRT_FilterFn,
|
||||
} from 'material-react-table'
|
||||
import { Box, CircularProgress, Paper, Tooltip } from '@mui/material'
|
||||
import { Alert, Box, CircularProgress, Paper, Tooltip } from '@mui/material'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import type { SerializedError } from '@reduxjs/toolkit'
|
||||
import type { FilterFn } from '@tanstack/table-core'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ActionComponent } from './ActionComponent'
|
||||
@@ -23,6 +25,7 @@ import '../../styles/TableView.css'
|
||||
import { TableToolBar } from './TableToolBar'
|
||||
import NotListedLocationIcon from '@mui/icons-material/NotListedLocation'
|
||||
import '../../styles/tableview/TableView.css'
|
||||
import { resolveErrorMessage, resolveErrorStatus } from './errorUtils'
|
||||
|
||||
type TableStateInUrl = 'sorting' | 'columnfilters' | 'pagination'
|
||||
|
||||
@@ -54,6 +57,8 @@ export const TableView = <T extends MRT_RowData>({
|
||||
isFetching,
|
||||
filterFns,
|
||||
renderRowActionExtras,
|
||||
isError,
|
||||
error,
|
||||
}: {
|
||||
data: T[] | undefined
|
||||
columns: MRT_ColumnDef<T>[]
|
||||
@@ -73,6 +78,8 @@ export const TableView = <T extends MRT_RowData>({
|
||||
enableColumnFilterModes?: boolean
|
||||
serverSidePagination?: boolean
|
||||
isFetching: boolean
|
||||
isError?: boolean
|
||||
error?: FetchBaseQueryError | SerializedError
|
||||
filterFns?: Record<string, MRT_FilterFn<T>>
|
||||
renderRowActionExtras?: ({ row }: { row: MRT_Row<T> }) => ReactNode
|
||||
}) => {
|
||||
@@ -325,6 +332,10 @@ export const TableView = <T extends MRT_RowData>({
|
||||
// renderToolbarInternalActions: selectorFn ? renderCustomToolbarModalVersion : renderCustomToolbar,
|
||||
})
|
||||
|
||||
const errorStatus = useMemo(() => resolveErrorStatus(error), [error])
|
||||
|
||||
const errorMessage = useMemo(() => resolveErrorMessage(errorStatus, error, isError), [errorStatus, error, isError])
|
||||
|
||||
// Load state from url only on first render
|
||||
useEffect(() => {
|
||||
if (selectorFn) return
|
||||
@@ -356,7 +367,16 @@ export const TableView = <T extends MRT_RowData>({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [table, columnFilters, sorting])
|
||||
|
||||
if (!data) {
|
||||
if (isError) {
|
||||
const severity = errorStatus === 401 || errorStatus === 403 ? 'warning' : 'error'
|
||||
return (
|
||||
<Alert severity={severity} role="alert">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data && isFetching) {
|
||||
return (
|
||||
<>
|
||||
<CircularProgress />
|
||||
@@ -366,6 +386,10 @@ export const TableView = <T extends MRT_RowData>({
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={5}>
|
||||
{user && (
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import type { SerializedError } from '@reduxjs/toolkit'
|
||||
|
||||
const isFetchBaseQueryError = (error: unknown): error is FetchBaseQueryError => {
|
||||
return typeof error === 'object' && error !== null && 'status' in error
|
||||
}
|
||||
|
||||
const extractStatus = (error: FetchBaseQueryError): number | null => {
|
||||
if (typeof error.status === 'number') {
|
||||
return error.status
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const extractMessageFromData = (data: unknown): string | null => {
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
const candidate = (data as { message?: unknown }).message
|
||||
if (typeof candidate === 'string') {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const resolveErrorStatus = (error: FetchBaseQueryError | SerializedError | undefined): number | null => {
|
||||
if (!error || !isFetchBaseQueryError(error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return extractStatus(error)
|
||||
}
|
||||
|
||||
export const resolveErrorMessage = (
|
||||
status: number | null,
|
||||
error: FetchBaseQueryError | SerializedError | undefined,
|
||||
isError: boolean | undefined
|
||||
): string | null => {
|
||||
if (!isError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (error && isFetchBaseQueryError(error)) {
|
||||
const message = extractMessageFromData(error.data)
|
||||
if (message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
return 'You need to sign in to view this data.'
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return 'You do not have permission to view this data.'
|
||||
}
|
||||
|
||||
return 'We were unable to load this data. Please try again later.'
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const TimeBoundTable = ({
|
||||
selectorFn?: (newTimeBound: TimeBound) => void
|
||||
showBid?: boolean
|
||||
}) => {
|
||||
const { data: timeBoundQueryData, isFetching } = useGetAllTimeBoundsQuery()
|
||||
const { data: timeBoundQueryData, isFetching, isError, error } = useGetAllTimeBoundsQuery()
|
||||
const columns = useMemo<MRT_ColumnDef<TimeBound>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -58,6 +58,8 @@ export const TimeBoundTable = ({
|
||||
data={timeBoundQueryData}
|
||||
url="time-bound"
|
||||
enableColumnFilterModes={true}
|
||||
isError={isError}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TimeUnitTable = ({
|
||||
selectorFn?: (newTimeUnit: TimeUnit) => void
|
||||
clickableRows?: boolean
|
||||
}) => {
|
||||
const { data: timeUnitQueryData, isFetching } = useGetAllTimeUnitsQuery()
|
||||
const { data: timeUnitQueryData, isFetching, isError, error } = useGetAllTimeUnitsQuery()
|
||||
const columns = useMemo<MRT_ColumnDef<TimeUnit>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -65,6 +65,8 @@ export const TimeUnitTable = ({
|
||||
idFieldName="tu_name"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
visibleColumns={visibleColumns}
|
||||
clickableRows={clickableRows}
|
||||
data={timeUnitQueryData}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -34,12 +34,12 @@ type UsePaginatedQueryOptions<TQueryArg, TRaw, TMapped> = {
|
||||
manualPageSize?: number
|
||||
}
|
||||
|
||||
type UnknownQueryResult<T> = {
|
||||
type UnknownQueryResult<T, TError = unknown> = {
|
||||
data?: T
|
||||
isFetching?: boolean
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: unknown
|
||||
error?: TError
|
||||
refetch?: () => unknown
|
||||
}
|
||||
|
||||
@@ -128,8 +128,10 @@ export const usePaginatedQuery = <
|
||||
TQueryArg,
|
||||
TRaw,
|
||||
TMapped = TRaw,
|
||||
TResult extends UnknownQueryResult<ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>> = UnknownQueryResult<
|
||||
ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>
|
||||
TError = unknown,
|
||||
TResult extends UnknownQueryResult<ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>, TError> = UnknownQueryResult<
|
||||
ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>,
|
||||
TError
|
||||
>,
|
||||
>(
|
||||
queryHook: QueryHook<TQueryArg, TResult>,
|
||||
@@ -236,8 +238,11 @@ export const usePaginatedQuery = <
|
||||
[transformedRows, metadata?.totalItems]
|
||||
)
|
||||
|
||||
const combinedIsError = Boolean(queryResult.isError) || Boolean(queryResult.error)
|
||||
|
||||
return {
|
||||
...queryResult,
|
||||
isError: combinedIsError,
|
||||
data: decoratedRows,
|
||||
pagination: metadata,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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 isAuthenticated = Boolean(user.token)
|
||||
|
||||
const { isError, error } = useGetPersonDetailsQuery(isAuthenticated ? queryArg : skipToken)
|
||||
|
||||
const isForbidden = useMemo(() => {
|
||||
if (!isError) return false
|
||||
const status = resolveErrorStatus(error)
|
||||
return status === 403
|
||||
}, [error, isError])
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<PermissionDenied
|
||||
title="Sign in to view this person"
|
||||
message="You must be signed in to access person records."
|
||||
actionHref="/login"
|
||||
actionLabel="Go to sign in"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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,30 @@
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import type { SerializedError } from '@reduxjs/toolkit'
|
||||
import { resolveErrorMessage, resolveErrorStatus } from '@/components/TableView/errorUtils'
|
||||
|
||||
describe('table view error utilities', () => {
|
||||
it('derives numeric status codes from fetch errors', () => {
|
||||
const fetchError: FetchBaseQueryError = { status: 403, data: {} }
|
||||
const serializedError: SerializedError = { name: 'Error', message: 'network failure' }
|
||||
expect(resolveErrorStatus(fetchError)).toBe(403)
|
||||
expect(resolveErrorStatus(serializedError)).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers backend messages when present', () => {
|
||||
const error = { status: 403, data: { message: 'Forbidden' } }
|
||||
const status = resolveErrorStatus(error)
|
||||
expect(resolveErrorMessage(status, error, true)).toBe('Forbidden')
|
||||
})
|
||||
|
||||
it('falls back to permission copy when backend provides none', () => {
|
||||
const error = { status: 403, data: {} }
|
||||
const status = resolveErrorStatus(error)
|
||||
expect(resolveErrorMessage(status, error, true)).toBe('You do not have permission to view this data.')
|
||||
})
|
||||
|
||||
it('returns null when not in error state', () => {
|
||||
const error = { status: 403, data: {} }
|
||||
const status = resolveErrorStatus(error)
|
||||
expect(resolveErrorMessage(status, error, false)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -59,4 +59,38 @@ describe('usePaginatedQuery', () => {
|
||||
pageSize: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks forbidden responses as errors without caching pagination metadata', () => {
|
||||
let latestIsError: boolean | undefined
|
||||
let latestError: unknown
|
||||
let latestPagination: TablesState[string] | null | undefined
|
||||
|
||||
const useForbiddenQuery = () => ({
|
||||
error: { status: 403, data: { message: 'Forbidden' } },
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
})
|
||||
|
||||
const TestComponent = () => {
|
||||
const result = usePaginatedQuery<void, SequenceRow>(useForbiddenQuery, {
|
||||
tableId: 'sequences',
|
||||
queryArg: undefined,
|
||||
})
|
||||
latestIsError = result.isError
|
||||
latestError = result.error
|
||||
latestPagination = result.pagination
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<TestComponent />
|
||||
</Provider>
|
||||
)
|
||||
|
||||
expect(latestIsError).toBe(true)
|
||||
expect(latestError).toEqual({ status: 403, data: { message: 'Forbidden' } })
|
||||
expect(latestPagination).toBeNull()
|
||||
expect(store.getState().tables?.sequences).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, 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 { PersonDetailPage } from '@/pages/PersonDetailPage'
|
||||
|
||||
jest.mock('@/components/Person/PersonDetails', () => ({
|
||||
PersonDetails: () => <div data-testid="person-details">details</div>,
|
||||
}))
|
||||
|
||||
const mockUseGetPersonDetailsQuery = jest.fn()
|
||||
const mockUseUser = jest.fn()
|
||||
|
||||
jest.mock('@/redux/personReducer', () => ({
|
||||
useGetPersonDetailsQuery: (arg?: unknown) => mockUseGetPersonDetailsQuery(arg),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/user', () => ({
|
||||
useUser: () => mockUseUser(),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Manual verification: log in as a non-admin user, navigate directly to `/person/<existing-id>`,
|
||||
* and confirm the permission message appears instead of the spinner.
|
||||
*/
|
||||
describe('PersonDetailPage', () => {
|
||||
beforeEach(() => {
|
||||
mockUseGetPersonDetailsQuery.mockReset()
|
||||
mockUseUser.mockReturnValue({ initials: 'JD', token: 'token-123' })
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('asks the visitor to sign in when no user token is present', () => {
|
||||
mockUseUser.mockReturnValue({ initials: null, token: null })
|
||||
mockUseGetPersonDetailsQuery.mockReturnValue({ isError: false })
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/person/JD']}>
|
||||
<Routes>
|
||||
<Route path="/person/:id" element={<PersonDetailPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Sign in to view this person')).toBeTruthy()
|
||||
expect(screen.queryByTestId('person-details')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import { PersonListPage } from '@/pages/PersonListPage'
|
||||
|
||||
jest.mock('@/components/Person/PersonTable', () => ({
|
||||
PersonTable: () => <div data-testid="person-table">table</div>,
|
||||
}))
|
||||
|
||||
const mockUseGetAllPersonsQuery = jest.fn()
|
||||
|
||||
jest.mock('@/redux/personReducer', () => ({
|
||||
useGetAllPersonsQuery: (arg?: unknown) => mockUseGetAllPersonsQuery(arg),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Manual verification: log in as a non-admin user and browse directly to `/person`.
|
||||
* Confirm the permission denied message renders instead of the loading indicator.
|
||||
*/
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user