Merge branch 'main' into locality-map-styles

This commit is contained in:
lllIIlIIlll
2025-06-12 14:28:42 +03:00
committed by GitHub
18 changed files with 126 additions and 41 deletions
+1
View File
@@ -14,6 +14,7 @@ export const existingPerson: Omit<PersonDetailsType, 'user'> = {
used_morph: null,
used_now: null,
used_gene: null,
now_user_group: 'eu',
}
export const editedPerson = {
+12
View File
@@ -3,6 +3,7 @@ import { getAllPersons, getPersonDetails, validateEntirePerson } from '../servic
import { Role, PersonDetailsType, EditDataType, EditMetaData } from '../../../frontend/src/shared/types'
import { requireOneOf } from '../middlewares/authorizer'
import { writePerson } from '../services/write/person'
import { UserGroup, validUserGroups, writeUserGroup } from '../services/write/user'
const router = Router()
@@ -53,6 +54,17 @@ router.put(
return res.status(403).send(validationErrors)
}
const initials = await writePerson(editedPerson)
/* Update user group (admin-only) */
if (
req.user.role === Role.Admin &&
editedPerson.user_id &&
editedPerson.now_user_group &&
validUserGroups.includes(editedPerson.now_user_group)
) {
await writeUserGroup(editedPerson.user_id, editedPerson.now_user_group as UserGroup)
}
return res.status(200).send({ initials })
}
)
+3 -6
View File
@@ -8,6 +8,7 @@ import { getRole } from '../middlewares/authenticator'
import { AccessError } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/shared/types'
import md5 from 'md5'
import { validatePassword } from '../utils/validatePassword'
const router = Router()
@@ -127,12 +128,8 @@ router.put('/password', async (req, res) => {
if (!passwordMatches) return res.status(403).send({ error: 'Old password does not match your current password.' })
if (newPassword.length < 8) return res.status(400).send({ error: 'New password must be at least 8 characters long.' })
if (!/^[0-9A-Za-z$%&~]+/.test(newPassword))
return res
.status(400)
.send({ error: 'Use only alphanumeric characters a-z, A-Z and 0-9 and symbols ^?$%&~ in the new password.' })
const validationResult = validatePassword(newPassword)
if (!validationResult.isValid) return res.status(400).send({ error: validationResult.error })
const passwordHash = await createPasswordHash(newPassword)
+1 -1
View File
@@ -27,7 +27,7 @@ export const getPersonDetails = async (id: string) => {
select: { user_id: true, user_name: true, last_login: true, now_user_group: true },
})
return { ...person, user }
return { ...person, user, now_user_group: user?.now_user_group }
}
export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_people>) => {
+11
View File
@@ -0,0 +1,11 @@
import { nowDb } from '../../utils/db'
export const validUserGroups = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
export type UserGroup = 'su' | 'eu' | 'er' | 'pl' | 'plp' | 'no' | 'ro'
export const writeUserGroup = async (user_id: number, group: UserGroup) => {
await nowDb.com_users.update({
where: { user_id: user_id },
data: { now_user_group: group },
})
}
@@ -0,0 +1,26 @@
import { describe, it, expect } from '@jest/globals'
import { validatePassword } from '../utils/validatePassword'
describe('Password validation utils', () => {
it('Accepts correct password', () => {
expect(validatePassword('thisisavalidpassword').isValid).toBeTruthy()
})
it('Accepts correct password with symbols', () => {
expect(validatePassword('thisisav~!%ali&dpas$sword').isValid).toBeTruthy()
})
it('Rejects invalid password with unsupported symbols in the middle', () => {
expect(validatePassword('thisisav~%a€li&dpas$sword').isValid).toBeFalsy()
})
it('Rejects invalid password with unsupported symbols in the beginning or end', () => {
expect(validatePassword('thisisav~%ali&dpas$sword€').isValid).toBeFalsy()
expect(validatePassword('€thisisav~%ali&dpas$sword').isValid).toBeFalsy()
})
it('Rejects password that is under 8 characters', () => {
expect(validatePassword('aaaaaaa').isValid).toBeFalsy()
expect(validatePassword('aaaaa').isValid).toBeFalsy()
})
it('Accepts password that is 8 characters or more', () => {
expect(validatePassword('aaaaaaaa').isValid).toBeTruthy()
expect(validatePassword('aaaaaaaaaaaa').isValid).toBeTruthy()
})
})
+15
View File
@@ -0,0 +1,15 @@
export interface PasswordValidationResult {
error?: string
isValid: boolean
}
export const validatePassword = (password: string): PasswordValidationResult => {
if (password.length < 8) return { isValid: false, error: 'Password must be at least 8 characters long' }
if (!/^[0-9A-Za-z$%&~!]+$/.test(password))
return {
isValid: false,
error: 'Password must only contain characters a-z, A-Z, 0-9 and ^?$%&~!',
}
return { isValid: true }
}
+15 -3
View File
@@ -30,6 +30,18 @@ describe('Editing a person', () => {
cy.contains('test.email@provider.com')
})
it('user rights editing works', () => {
cy.visit(`/person/AD?tab=0`)
cy.get('[id=edit-button]').click()
cy.contains('Choose user group').parent().type('n')
cy.contains('no').click()
cy.get('[id=write-button]').click()
cy.contains('no')
})
it('with any missing fields does not work', () => {
cy.visit(`/person/AD?tab=0`)
cy.contains('ads')
@@ -55,9 +67,9 @@ describe('Editing a person', () => {
cy.get('[id=organization-textfield]').type('test organization')
cy.get('[id=Country-multiselect]').click()
cy.get('[data-testid=CloseIcon]').click() // clears the country dropdown
cy.contains('Country: This field is required')
cy.get('[id=write-button]').should('be.disabled')
// cy.get('[id=Country-multiselect]').children('[data-testid=CloseIcon]').click() // clears the country dropdown
// cy.contains('Country: This field is required')
// cy.get('[id=write-button]').should('be.disabled')
cy.get('[id=Country-multiselect]').type('Andorra')
cy.contains('.MuiAutocomplete-option', 'Andorra').click() // this finds the Andorra inside the Autocomplete component
+3 -3
View File
@@ -20,7 +20,7 @@ describe('Change password form validates passwords correctly and shows errors to
cy.contains('Please fill all fields.')
cy.get('[id=new-password-verification-textfield]').type('test2')
cy.get('[id=change-password-button]').click()
cy.contains('New password must be at least 8 characters long.')
cy.contains('Password must be at least 8 characters long')
})
it('Incorrect old password', () => {
@@ -40,14 +40,14 @@ describe('Change password form validates passwords correctly and shows errors to
cy.get('[id=new-password-textfield]').type('short')
cy.get('[id=new-password-verification-textfield]').type('short')
cy.get('[id=change-password-button]').click()
cy.contains('New password must be at least 8 characters long.')
cy.contains('Password must be at least 8 characters long')
cy.get('[id=new-password-textfield]').clear()
cy.get('[id=new-password-textfield]').type('€uroisnotallowed')
cy.get('[id=new-password-verification-textfield]').clear()
cy.get('[id=new-password-verification-textfield]').type('€uroisnotallowed')
cy.get('[id=change-password-button]').click()
cy.contains('Use only alphanumeric characters a-z, A-Z and 0-9 and symbols ^?$%&~ in the new password.')
cy.contains('Password must only contain characters a-z, A-Z, 0-9 and ^?$%&~!')
})
it('New password and verification do not match', () => {
@@ -807,10 +807,7 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
return (
<>
<LocalitiesMap
localitiesQueryData={localitiesData}
localitiesQueryIsFetching={localitiesFetching || isFetching}
/>
<LocalitiesMap localities={localitiesData} isFetching={localitiesFetching || isFetching} />
<TableView<CrossSearch>
title="Locality-Species-Cross-Search"
selectorFn={selectorFn}
@@ -71,7 +71,8 @@ export type DetailContextType<T> = {
field: keyof EditDataType<T>,
options: Array<DropdownOption | string>,
name: string,
disabled?: boolean
disabled?: boolean,
label?: string
) => JSX.Element
radioSelection: (
field: keyof EditDataType<T>,
@@ -114,8 +114,9 @@ export const DetailView = <T extends object>({
field: keyof EditDataType<T>,
options: Array<DropdownOption | string>,
name: string,
disabled?: boolean
) => <DropdownSelectorWithSearch field={field} options={options} name={name} disabled={disabled} />
disabled?: boolean,
label?: string
) => <DropdownSelectorWithSearch field={field} options={options} name={name} disabled={disabled} label={label} />
const onDelete = () => {
if (!window.confirm('Are you sure you want to delete this item? This operation cannot be undone.')) return
@@ -98,11 +98,13 @@ export const DropdownSelectorWithSearch = <T extends object>({
name,
field,
disabled,
label,
}: {
options: Array<DropdownOption | string>
name: string
field: keyof EditDataType<T>
disabled?: boolean
label?: string
}) => {
const { setEditData, editData, validator, fieldsWithErrors, setFieldsWithErrors } = useDetailContext<T>()
const errorObject = validator(editData, field)
@@ -139,7 +141,7 @@ export const DropdownSelectorWithSearch = <T extends object>({
setInputValue(newInputValue)
}}
sx={{ width: fieldWidth, backgroundColor: disabled ? 'grey' : '' }}
renderInput={params => <TextField {...params} label="Choose a country" error={!!error} />}
renderInput={params => <TextField {...params} label={label ?? 'Choose a country'} error={!!error} />}
/>
{error && <FormHelperText>{error}</FormHelperText>}
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { MRT_TableInstance, type MRT_ColumnDef, MRT_RowData } from 'material-react-table'
import { useGetAllLocalitiesQuery, useGetLocalitySpeciesListMutation } from '../../redux/localityReducer'
import { Locality } from '@/shared/types'
import { Locality, SimplifiedLocality } from '@/shared/types'
import { TableView } from '../TableView/TableView'
import { useNotify } from '@/hooks/notification'
import { LocalitiesMap } from '../Map/LocalitiesMap'
@@ -20,8 +20,18 @@ const decimalCount = (num: number) => {
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
const notify = useNotify()
const [filteredLocalities, setFilteredLocalities] = useState<SimplifiedLocality[]>()
const columnFilters = usePageContext()
const notify = useNotify()
useEffect(() => {
// Filter localities for the map component
const localityIds = columnFilters.idList as unknown as number[]
const validIds = localityIds.filter(id => typeof id === 'number')
const localities = localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
setFilteredLocalities(localities as SimplifiedLocality[])
}, [columnFilters, localitiesQueryData])
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
() => [
{
@@ -415,16 +425,9 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
return !!row.loc_status
}
// Filter localities for the map component
const localityIds = columnFilters.idList as unknown as number[]
const validIds = localityIds.filter(id => typeof id === 'number')
const filteredLocalities = localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
return (
<>
{filteredLocalities && (
<LocalitiesMap localitiesQueryData={filteredLocalities} localitiesQueryIsFetching={localitiesQueryIsFetching} />
)}
<LocalitiesMap localities={filteredLocalities} isFetching={localitiesQueryIsFetching} />
<TableView<Locality>
title="Localities"
selectorFn={selectorFn}
@@ -17,14 +17,14 @@ import '../../styles/LocalitiesMap.css'
import northarrow from './images/north-arrow.png'
interface Props {
localitiesQueryData?: SimplifiedLocality[]
localitiesQueryIsFetching: boolean
localities?: SimplifiedLocality[]
isFetching: boolean
}
type CustomCircleMarkerOptions = L.CircleMarkerOptions & { localityId: number }
type CustomCircleMarker = L.CircleMarker & { options: CustomCircleMarkerOptions }
export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }: Props) => {
export const LocalitiesMap = ({ localities, isFetching }: Props) => {
const [selectedLocality, setSelectedLocality] = useState<string | null>(null)
const mapRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<L.Map | null>(null)
@@ -107,7 +107,7 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
}, [])
useEffect(() => {
if (!map || localitiesQueryIsFetching) return
if (!map || isFetching) return
// To prevent eslint from complaining about that 'markers' variable below:
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
@@ -119,7 +119,7 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
// with no module exports that extends 'L' when imported
const newMarkers: Layer = cluster ? L.markerClusterGroup() : L.layerGroup()
localitiesQueryData?.forEach(locality => {
localities?.forEach(locality => {
const options: CustomCircleMarkerOptions = {
localityId: locality.lid,
radius: 4,
@@ -150,7 +150,7 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
map.removeLayer(newMarkers)
}
}, [localitiesQueryData, localitiesQueryIsFetching, columnFilters, map, cluster])
}, [localities, isFetching, columnFilters, map, cluster])
document.title = 'Map'
@@ -165,7 +165,7 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
</button>
)}
</div>
{!localitiesQueryIsFetching && localitiesQueryData && (
{!isFetching && localities && (
<div className="button-row">
<Button variant="contained" startIcon={<MapIcon />} onClick={() => setIsOpen(v => !v)}>
{isOpen ? 'Close' : 'Open'} map
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@ export const PersonTab = () => {
}, [currentUser])
const countryOptions = ['', ...validCountries]
const userGroupOptions = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
const person = [
['Initials', textField('initials', { type: 'text', disabled: true })],
@@ -37,7 +38,10 @@ export const PersonTab = () => {
? [
['User Name', data.user?.user_name ?? ''],
['Last log in', lastLogin ? formatLastLoginDate(lastLogin) : 'No data'],
['User Group', data.user?.now_user_group ?? ''],
[
'User Group',
dropdownWithSearch('now_user_group', userGroupOptions, 'User Group', false, 'Choose user group'),
],
]
: [['Not a user']]
+3 -1
View File
@@ -15,7 +15,9 @@ export type SpeciesLocality = FixBigInt<Prisma.now_ls> & { now_loc: Prisma.now_l
export type LocalityUpdate = Prisma.now_lau & { now_lr: LocalityReference[] } & { updates: UpdateLog[] }
export type SpeciesUpdate = Prisma.now_sau & { now_sr: SpeciesReference[] } & { updates: UpdateLog[] }
export type Museum = Prisma.com_mlist
export type PersonDetailsType = Prisma.com_people & { user: Omit<Prisma.com_users, 'password, newpassword'> | null }
export type PersonDetailsType = Prisma.com_people & { user: Omit<Prisma.com_users, 'password, newpassword'> | null } & {
now_user_group: string
}
export type ProjectPeople = Prisma.now_proj_people
export type ProjectDetailsType = Prisma.now_proj & { now_proj_people: Array<ProjectPeople> }
export type Project = Prisma.now_proj