From ca1bd74de513ec26f8a09c6cd5ed95b07d8407c2 Mon Sep 17 00:00:00 2001 From: Andreas Fischer <37902063+f1sh1918@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:45:17 +0100 Subject: [PATCH] 1670: Show form hints (#1761) * 1670: add hints for name data privacy and referenceNr, adjust error message * 1650: added line breaks to help texts * 1670: disallow multi spaces. move replace multi spaces to utils, fix small issues * 1670: add check for emojis to name and referenceNr, allow empty space at the beginning and end of the name but trim it in generateCardInfo * 1670: remove emoji check, add xregexp check for common and latin charset * 1670: fix package json lock --- administration/package.json | 4 +- .../self-service/CardSelfServiceForm.tsx | 70 +++++++++++-------- .../self-service/CardSelfServiceView.tsx | 14 +++- .../components/FormErrorMessage.tsx | 27 +++++++ ...ice.ts => useCardGeneratorSelfService.tsx} | 7 +- administration/src/cards/Card.test.ts | 33 +++++++++ administration/src/cards/Card.ts | 33 +++++++-- .../KoblenzReferenceNumberExtension.tsx | 27 ++++--- administration/src/errors/DefaultErrorMap.tsx | 2 +- administration/src/util/helper.ts | 14 ++++ package-lock.json | 30 +++++++- 11 files changed, 209 insertions(+), 52 deletions(-) create mode 100644 administration/src/bp-modules/self-service/components/FormErrorMessage.tsx rename administration/src/bp-modules/self-service/hooks/{useCardGeneratorSelfService.ts => useCardGeneratorSelfService.tsx} (95%) diff --git a/administration/package.json b/administration/package.json index 1d6063415..da181e679 100644 --- a/administration/package.json +++ b/administration/package.json @@ -30,6 +30,7 @@ "i18next": "^23.16.4", "localforage": "^1.10.0", "normalize.css": "^8.0.1", + "normalize-strings": "^1.1.1", "notistack": "^3.0.1", "pdf-lib": "^1.17.1", "react": "^18.2.0", @@ -37,7 +38,8 @@ "react-flip-move": "^3.0.5", "react-i18next": "^15.1.0", "react-router-dom": "^6.14.0", - "styled-components": "^5.3.11" + "styled-components": "^5.3.11", + "xregexp": "^5.1.1" }, "devDependencies": { "@babel/core": "^7.22.5", diff --git a/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx b/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx index c6af9e6d1..95eb0376c 100644 --- a/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx +++ b/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx @@ -1,15 +1,19 @@ import { Checkbox, FormGroup, InputGroup, Intent } from '@blueprintjs/core' import InfoOutlined from '@mui/icons-material/InfoOutlined' -import { Alert, styled } from '@mui/material' +import { styled } from '@mui/material' import React, { ReactElement, useContext, useState } from 'react' -import { Card, isFullNameValid, isValid } from '../../cards/Card' +import { Card, getFullNameValidationErrorMessage, isFullNameValid, isValid } from '../../cards/Card' import ClearInputButton from '../../cards/extensions/components/ClearInputButton' import useWindowDimensions from '../../hooks/useWindowDimensions' import BasicDialog from '../../mui-modules/application/BasicDialog' import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import { removeMultipleSpaces } from '../../util/helper' +import { useAppToaster } from '../AppToaster' import ExtensionForms from '../cards/ExtensionForms' +import { DataPrivacyAcceptingStatus } from './CardSelfServiceView' import { ActionButton } from './components/ActionButton' +import FormErrorMessage from './components/FormErrorMessage' import { IconTextButton } from './components/IconTextButton' import { UnderlineTextButton } from './components/UnderlineTextButton' @@ -19,11 +23,6 @@ const StyledCheckbox = styled(Checkbox)` margin-left: 4px; ` -const StyledAlert = styled(Alert)` - margin-bottom: 24px; - white-space: pre-line; -` - const Container = styled('div')` margin-bottom: 24px; ` @@ -31,22 +30,11 @@ const Container = styled('div')` type CardSelfServiceFormProps = { card: Card updateCard: (card: Partial) => void - dataPrivacyAccepted: boolean - setDataPrivacyAccepted: (value: boolean) => void + dataPrivacyAccepted: DataPrivacyAcceptingStatus + setDataPrivacyAccepted: (status: DataPrivacyAcceptingStatus) => void generateCards: () => Promise } -const getTooltipMessage = (cardsValid: boolean, dataPrivacyAccepted: boolean): string => { - const tooltipMessages: string[] = [] - if (!cardsValid) { - tooltipMessages.push('Mindestens eine Ihrer Angaben ist ungültig.') - } - if (!dataPrivacyAccepted) { - tooltipMessages.push('Bitte akzeptieren Sie die Datenschutzerklärung.') - } - - return tooltipMessages.join('\n') -} const CardSelfServiceForm = ({ card, updateCard, @@ -59,7 +47,24 @@ const CardSelfServiceForm = ({ const [openDataPrivacy, setOpenDataPrivacy] = useState(false) const [openReferenceInformation, setOpenReferenceInformation] = useState(false) const cardValid = isValid(card, { expirationDateNullable: true }) - const cardCreationDisabled = !cardValid || !dataPrivacyAccepted + const appToaster = useAppToaster() + + const createKoblenzPass = async () => { + if (cardValid && dataPrivacyAccepted === DataPrivacyAcceptingStatus.accepted) { + await generateCards() + return + } + if (dataPrivacyAccepted === DataPrivacyAcceptingStatus.untouched) { + setDataPrivacyAccepted(DataPrivacyAcceptingStatus.denied) + } + appToaster?.show({ + message: ( + + ), + timeout: 0, + intent: 'danger', + }) + } return ( <> @@ -78,25 +83,32 @@ const CardSelfServiceForm = ({ } intent={isFullNameValid(card) ? undefined : Intent.DANGER} value={card.fullName} - onChange={event => updateCard({ fullName: event.target.value })} + onChange={event => updateCard({ fullName: removeMultipleSpaces(event.target.value) })} /> + setOpenReferenceInformation(true)}> Wo finde ich das Aktenzeichen? - setDataPrivacyAccepted(!dataPrivacyAccepted)}> + + setDataPrivacyAccepted( + dataPrivacyAccepted === DataPrivacyAcceptingStatus.accepted + ? DataPrivacyAcceptingStatus.denied + : DataPrivacyAcceptingStatus.accepted + ) + }> Ich akzeptiere die{' '} setOpenDataPrivacy(true)}>Datenschutzerklärung. + {dataPrivacyAccepted === DataPrivacyAcceptingStatus.denied && ( + + )} - {cardCreationDisabled && ( - - {getTooltipMessage(cardValid, dataPrivacyAccepted)} - - )} - + KoblenzPass erstellen { const projectConfig = useContext(ProjectConfigContext) - const [dataPrivacyAccepted, setDataPrivacyAccepted] = useState(false) + const [dataPrivacyCheckbox, setDataPrivacyCheckbox] = useState( + DataPrivacyAcceptingStatus.untouched + ) const { selfServiceState, setSelfServiceState, @@ -127,8 +135,8 @@ const CardSelfServiceView = (): ReactElement => { {selfServiceState === CardSelfServiceStep.form && ( setSelfServiceCard(updateCard(selfServiceCard, updatedCard))} generateCards={generateCards} /> diff --git a/administration/src/bp-modules/self-service/components/FormErrorMessage.tsx b/administration/src/bp-modules/self-service/components/FormErrorMessage.tsx new file mode 100644 index 000000000..1e9cea2eb --- /dev/null +++ b/administration/src/bp-modules/self-service/components/FormErrorMessage.tsx @@ -0,0 +1,27 @@ +import InfoOutlined from '@mui/icons-material/InfoOutlined' +import { styled } from '@mui/material' +import React, { CSSProperties, ReactElement } from 'react' + +const Container = styled('div')` + margin: 6px 0; + color: #ba1a1a; + display: flex; + gap: 8px; + align-items: center; + font-size: 14px; +` + +type FormErrorMessageProps = { + errorMessage: string | null + style?: CSSProperties +} + +const FormErrorMessage = ({ errorMessage, style }: FormErrorMessageProps): ReactElement | null => + errorMessage ? ( + + + {errorMessage} + + ) : null + +export default FormErrorMessage diff --git a/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.ts b/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx similarity index 95% rename from administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.ts rename to administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx index 041edde93..a7469dbed 100644 --- a/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.ts +++ b/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx @@ -1,5 +1,5 @@ import { ApolloError } from '@apollo/client' -import { useCallback, useContext, useState } from 'react' +import React, { useCallback, useContext, useState } from 'react' import { Card, generateCardInfo, initializeCard } from '../../../cards/Card' import { generatePdf } from '../../../cards/PdfFactory' @@ -12,6 +12,7 @@ import { base64ToUint8Array, uint8ArrayToBase64 } from '../../../util/base64' import downloadDataUri from '../../../util/downloadDataUri' import getCustomDeepLinkFromQrCode from '../../../util/getCustomDeepLinkFromQrCode' import { useAppToaster } from '../../AppToaster' +import FormErrorMessage from '../components/FormErrorMessage' export enum CardSelfServiceStep { form, @@ -54,9 +55,9 @@ const useCardGeneratorSelfService = (): UseCardGeneratorSelfServiceReturn => { if (error instanceof ApolloError) { const { title } = getMessageFromApolloError(error) appToaster?.show({ - message: title, - intent: 'danger', + message: , timeout: 0, + intent: 'danger', }) } else { appToaster?.show({ diff --git a/administration/src/cards/Card.test.ts b/administration/src/cards/Card.test.ts index 0ed056912..4cfc1d0db 100644 --- a/administration/src/cards/Card.test.ts +++ b/administration/src/cards/Card.test.ts @@ -2,6 +2,7 @@ import { BavariaCardType } from '../generated/card_pb' import { Region } from '../generated/graphql' import PlainDate from '../util/PlainDate' import { + MAX_NAME_LENGTH, generateCardInfo, getValueByCSVHeader, initializeCard, @@ -139,6 +140,38 @@ describe('Card', () => { }) }) + it.each(['$tefan Mayer', 'Karla K.', 'Karla Karls😀', 'إئبآء؟ؤئحجرزش'])( + 'should correctly identify invalid special characters in fullname', + fullName => { + const card = initializeCard(cardConfig, region, { fullName }) + expect(card.fullName).toBe(fullName) + expect(isValueValid(card, cardConfig, 'Name')).toBeFalsy() + expect(isValid(card)).toBeFalsy() + } + ) + + it.each([' Karla Koblenz', ' Karla Karl', ' Karla Karls '])( + 'should correctly create a card even with whitespace in the beginning and end', + fullName => { + const card = initializeCard(cardConfig, region, { fullName }) + expect(card.fullName).toBe(fullName) + expect(isValueValid(card, cardConfig, 'Name')).toBeTruthy() + expect(isValid(card)).toBeTruthy() + } + ) + + it.each(['Karla', 'Karl L'])('should correctly identify invalid fullname that is incomplete', fullName => { + const card = initializeCard(cardConfig, region, { fullName }) + expect(isValueValid(card, cardConfig, 'Name')).toBeFalsy() + expect(isValid(card)).toBeFalsy() + }) + + it(`should correctly identify invalid fullname that exceeds max length (${MAX_NAME_LENGTH} characters)`, () => { + const card = initializeCard(cardConfig, region, { fullName: 'Karl LauterLauterLauterLauterLauterLauterLauterbach' }) + expect(isValueValid(card, cardConfig, 'Name')).toBeFalsy() + expect(isValid(card)).toBeFalsy() + }) + describe('self service', () => { const cardConfig = { defaultValidity: { years: 3 }, diff --git a/administration/src/cards/Card.ts b/administration/src/cards/Card.ts index 38a7bf6c1..b821b3af2 100644 --- a/administration/src/cards/Card.ts +++ b/administration/src/cards/Card.ts @@ -5,11 +5,12 @@ import { CardExtensions, CardInfo } from '../generated/card_pb' import { Region } from '../generated/graphql' import { CardConfig } from '../project-configs/getProjectConfig' import PlainDate from '../util/PlainDate' +import { containsOnlyLatinAndCommonCharset, containsSpecialCharacters, removeMultipleSpaces } from '../util/helper' import { REGION_EXTENSION_NAME } from './extensions/RegionExtension' import Extensions, { Extension, ExtensionKey, ExtensionState, InferExtensionStateType } from './extensions/extensions' // Due to limited space on the cards -const MAX_NAME_LENGTH = 30 +export const MAX_NAME_LENGTH = 30 // Due to limited space on the qr code const MAX_ENCODED_NAME_LENGTH = 50 @@ -66,12 +67,22 @@ export const getExtensions = ({ extensions }: Card): ExtensionWithState[] => { export const hasInfiniteLifetime = (card: Card): boolean => getExtensions(card).some(({ extension, state }) => extension.causesInfiniteLifetime(state)) - -export const isFullNameValid = ({ fullName }: Card): boolean => { +const hasValidNameLength = (fullName: string): boolean => { const encodedName = new TextEncoder().encode(fullName) return fullName.length > 0 && encodedName.length <= MAX_ENCODED_NAME_LENGTH && fullName.length <= MAX_NAME_LENGTH } +const hasNameAndForename = (fullName: string): boolean => { + const names = removeMultipleSpaces(fullName).trim().split(' ') + return names.length > 1 && names.every(name => name.length > 1) +} + +export const isFullNameValid = ({ fullName }: Card): boolean => + hasValidNameLength(fullName) && + hasNameAndForename(fullName) && + containsOnlyLatinAndCommonCharset(fullName) && + !containsSpecialCharacters(fullName) + export const isExpirationDateValid = (card: Card, { nullable } = { nullable: false }): boolean => { const today = PlainDate.fromLocalDate(new Date()) const startDay = card.extensions.startDay @@ -103,7 +114,7 @@ export const generateCardInfo = (card: Card): CardInfo => { expirationDate !== null && !hasInfiniteLifetime(card) ? Math.max(expirationDate.toDaysSinceEpoch(), 0) : undefined return new CardInfo({ - fullName: card.fullName, + fullName: card.fullName.trim(), expirationDay, extensions: new CardExtensions(extensionsMessage), }) @@ -182,3 +193,17 @@ export const updateCard = (oldCard: Card, updatedCard: Partial): Card => ( ...(updatedCard.extensions ?? {}), }, }) + +export const getFullNameValidationErrorMessage = (name: string): string | null => { + const errors: string[] = [] + if (!containsOnlyLatinAndCommonCharset(name) || containsSpecialCharacters(name)) { + errors.push('Der Name darf keine Sonderzeichen oder Zahlen enthalten.') + } + if (!hasNameAndForename(name)) { + errors.push('Bitte geben Sie Ihren vollständigen Namen ein.') + } + if (!hasValidNameLength(name)) { + errors.push(`Der Name darf nicht länger als ${MAX_NAME_LENGTH} Zeichen sein`) + } + return errors.join(' ') +} diff --git a/administration/src/cards/extensions/KoblenzReferenceNumberExtension.tsx b/administration/src/cards/extensions/KoblenzReferenceNumberExtension.tsx index 3f69c881b..041b139e5 100644 --- a/administration/src/cards/extensions/KoblenzReferenceNumberExtension.tsx +++ b/administration/src/cards/extensions/KoblenzReferenceNumberExtension.tsx @@ -1,6 +1,7 @@ import { FormGroup, InputGroup, Intent } from '@blueprintjs/core' import React, { ReactElement } from 'react' +import FormErrorMessage from '../../bp-modules/self-service/components/FormErrorMessage' import useWindowDimensions from '../../hooks/useWindowDimensions' import ClearInputButton from './components/ClearInputButton' import { Extension, ExtensionComponentProps } from './extensions' @@ -11,6 +12,9 @@ type KoblenzReferenceNumberExtensionState = { [KOBLENZ_REFERENCE_NUMBER_EXTENSIO const KoblenzReferenceNumberMinLength = 4 const KoblenzReferenceNumberMaxLength = 15 +const hasSpecialChars = (referenceNr: string): boolean => /[`!@#$%^&*()_+\-=\]{};':"\\|,<>?~]/.test(referenceNr) +const hasInvalidLength = (referenceNumberLength: number): boolean => + referenceNumberLength < KoblenzReferenceNumberMinLength || referenceNumberLength > KoblenzReferenceNumberMaxLength const KoblenzReferenceNumberExtensionForm = ({ value, @@ -20,6 +24,16 @@ const KoblenzReferenceNumberExtensionForm = ({ const { viewportSmall } = useWindowDimensions() const clearInput = () => setValue({ koblenzReferenceNumber: '' }) + const getErrorMessage = (): string | null => { + if (hasSpecialChars(value.koblenzReferenceNumber)) { + return 'Das Aktenzeichen enthält ungültige Sonderzeichen.' + } + if (hasInvalidLength(value.koblenzReferenceNumber.length)) { + return `Das Aktenzeichen muss eine Länge zwischen ${KoblenzReferenceNumberMinLength} und ${KoblenzReferenceNumberMaxLength} haben.` + } + return null + } + return ( } - onChange={event => { - const value = event.target.value - if (value.length <= KoblenzReferenceNumberMaxLength) { - setValue({ koblenzReferenceNumber: value }) - } - }} + onChange={event => setValue({ koblenzReferenceNumber: event.target.value })} /> + ) } @@ -63,7 +71,8 @@ const KoblenzReferenceNumberExtension: Extension= KoblenzReferenceNumberMinLength && - koblenzReferenceNumber.length <= KoblenzReferenceNumberMaxLength + koblenzReferenceNumber.length <= KoblenzReferenceNumberMaxLength && + !hasSpecialChars(koblenzReferenceNumber) ) }, fromString: value => ({ koblenzReferenceNumber: value }), diff --git a/administration/src/errors/DefaultErrorMap.tsx b/administration/src/errors/DefaultErrorMap.tsx index 8f87cb536..a9043f9f5 100644 --- a/administration/src/errors/DefaultErrorMap.tsx +++ b/administration/src/errors/DefaultErrorMap.tsx @@ -100,7 +100,7 @@ const defaultErrorMap = (extensions?: ErrorExtensions): GraphQLErrorMessage => { case GraphQlExceptionCode.UserEntitlementExpired: return { title: - 'Sie sind nicht länger berechtigt einen KoblenzPass zu erstellen. Bitte kontaktieren Sie koblenzpass@stadt.koblenz.de für weitere Informationen.', + 'Sie sind nicht länger berechtigt, einen KoblenzPass zu erstellen. Bitte kontaktieren Sie koblenzpass@stadt.koblenz.de für weitere Informationen.', } case GraphQlExceptionCode.MailNotSent: return { diff --git a/administration/src/util/helper.ts b/administration/src/util/helper.ts index 535b442d9..6e289b871 100644 --- a/administration/src/util/helper.ts +++ b/administration/src/util/helper.ts @@ -1,3 +1,5 @@ +import XRegExp from 'xregexp' + export const isDevMode = (): boolean => window.location.hostname === 'localhost' export const isStagingMode = (): boolean => !!window.location.hostname.match(/staging./) @@ -10,3 +12,15 @@ export const updateArrayItem = (array: T[], updatedItem: T, index: number): T // eslint-disable-next-line @typescript-eslint/no-explicit-any export type UnionToIntersection = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never + +const multipleSpacePattern = /\s\s+/g +export const removeMultipleSpaces = (value: string): string => value.replace(multipleSpacePattern, ' ') +export const containsSpecialCharacters = (value: string): boolean => + /[`!@#$%^&*()_+\-=\]{};':"\\|,.<>?~0123456789]/.test(value) + +/** This regEx is needed to avoid breaking pdf creation due to incompatible charsets in form fields + * Common charset includes common pattern f.e. empty spaces. + * Checking for latin charset should be fine because all fonts we're going to use for pdf generation should be latin based. + * */ +export const containsOnlyLatinAndCommonCharset = (value: string): boolean => + XRegExp('^[\\p{Latin}\\p{Common}]+$').test(value) diff --git a/package-lock.json b/package-lock.json index c7f8e37f5..81ee1c072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "graphql": "^16.8.1", "i18next": "^23.16.4", "localforage": "^1.10.0", + "normalize-strings": "^1.1.1", "normalize.css": "^8.0.1", "notistack": "^3.0.1", "pdf-lib": "^1.17.1", @@ -44,7 +45,8 @@ "react-flip-move": "^3.0.5", "react-i18next": "^15.1.0", "react-router-dom": "^6.14.0", - "styled-components": "^5.3.11" + "styled-components": "^5.3.11", + "xregexp": "^5.1.1" }, "devDependencies": { "@babel/core": "^7.22.5", @@ -2717,6 +2719,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz", + "integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", @@ -10280,7 +10294,6 @@ "version": "3.38.1", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.38.1.tgz", "integrity": "sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "funding": { @@ -18978,6 +18991,11 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-strings": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/normalize-strings/-/normalize-strings-1.1.1.tgz", + "integrity": "sha512-fARPRdTwmrQDLYhmeh7j/eZwrCP6WzxD6uKOdK/hT/uKACAE9AG2Bc2dgqOZLkfmmctHpfcJ9w3AQnfLgg3GYg==" + }, "node_modules/normalize.css": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz", @@ -24741,6 +24759,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xregexp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-5.1.1.tgz", + "integrity": "sha512-fKXeVorD+CzWvFs7VBuKTYIW63YD1e1osxwQ8caZ6o1jg6pDAbABDG54LCIq0j5cy7PjRvGIq6sef9DYPXpncg==", + "dependencies": { + "@babel/runtime-corejs3": "^7.16.5" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",