mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge branch 'main' into locality-map-styles
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user