diff --git a/apps/contentful-apps/components/EntryListSearch.tsx b/apps/contentful-apps/components/EntryListSearch.tsx new file mode 100644 index 000000000000..90efc883c5ab --- /dev/null +++ b/apps/contentful-apps/components/EntryListSearch.tsx @@ -0,0 +1,155 @@ +import { useRef, useState } from 'react' +import { useDebounce } from 'react-use' +import { + CollectionProp, + EntryProps, + KeyValueMap, + QueryOptions, +} from 'contentful-management' +import { + Box, + EntryCard, + Pagination, + Spinner, + Stack, + Text, + TextInput, +} from '@contentful/f36-components' +import { useCMA, useSDK } from '@contentful/react-apps-toolkit' + +import { DEFAULT_LOCALE } from '../constants' + +const SEARCH_DEBOUNCE_TIME_IN_MS = 300 + +interface EntryListSearchProps { + contentTypeId: string + contentTypeLabel: string + contentTypeTitleField: string + itemsPerPage?: number + onEntryClick?: (entry: EntryProps) => void + query?: QueryOptions +} + +export const EntryListSearch = ({ + itemsPerPage = 4, + contentTypeId, + contentTypeLabel, + contentTypeTitleField, + onEntryClick, + query, +}: EntryListSearchProps) => { + const sdk = useSDK() + const cma = useCMA() + + const searchValueRef = useRef('') + const [searchValue, setSearchValue] = useState('') + const [listItemResponse, setListItemResponse] = + useState>>() + const [loading, setLoading] = useState(false) + const [page, setPage] = useState(0) + const pageRef = useRef(0) + const [counter, setCounter] = useState(0) + + const skip = itemsPerPage * page + + useDebounce( + async () => { + setLoading(true) + try { + const response = await cma.entry.getMany({ + query: { + content_type: contentTypeId, + limit: itemsPerPage, + skip, + [`fields.${contentTypeTitleField}[match]`]: searchValue, + 'sys.archivedAt[exists]': false, + ...query, + }, + }) + if ( + searchValueRef.current === searchValue && + pageRef.current === page + ) { + setListItemResponse(response) + } + } finally { + setLoading(false) + } + }, + SEARCH_DEBOUNCE_TIME_IN_MS, + [page, searchValue, counter], + ) + + return ( + + { + searchValueRef.current = ev.target.value + setSearchValue(ev.target.value) + setPage(0) + pageRef.current = 0 + }} + /> + + + + + + {listItemResponse?.items?.length > 0 && ( + <> + + + {listItemResponse.items.map((item) => ( + { + if (onEntryClick) { + onEntryClick(item) + return + } + + sdk.navigator + .openEntry(item.sys.id, { + slideIn: { waitForClose: true }, + }) + .then(() => { + setCounter((c) => c + 1) + }) + }} + /> + ))} + + + { + pageRef.current = newPage + setPage(newPage) + }} + /> + + )} + + {listItemResponse?.items?.length === 0 && ( + + No entry was found + + )} + + ) +} diff --git a/apps/contentful-apps/components/editors/lists/GenericListEditor/GenericListEditor.tsx b/apps/contentful-apps/components/editors/lists/GenericListEditor/GenericListEditor.tsx index 0740aff1d59d..2eac9ec947d2 100644 --- a/apps/contentful-apps/components/editors/lists/GenericListEditor/GenericListEditor.tsx +++ b/apps/contentful-apps/components/editors/lists/GenericListEditor/GenericListEditor.tsx @@ -1,27 +1,14 @@ -import { useMemo, useRef, useState } from 'react' -import { useDebounce } from 'react-use' -import { CollectionProp, EntryProps, KeyValueMap } from 'contentful-management' +import { useMemo, useState } from 'react' import dynamic from 'next/dynamic' import { EditorExtensionSDK } from '@contentful/app-sdk' -import { - Box, - Button, - EntryCard, - FormControl, - Pagination, - Spinner, - Stack, - Text, - TextInput, -} from '@contentful/f36-components' +import { Box, Button, FormControl } from '@contentful/f36-components' import { PlusIcon } from '@contentful/f36-icons' import { useCMA, useSDK } from '@contentful/react-apps-toolkit' +import { EntryListSearch } from '../../../../components/EntryListSearch' import { mapLocalesToFieldApis } from '../../utils' -const SEARCH_DEBOUNCE_TIME_IN_MS = 300 const LIST_ITEM_CONTENT_TYPE_ID = 'genericListItem' -const LIST_ITEMS_PER_PAGE = 4 const createLocaleToFieldMapping = (sdk: EditorExtensionSDK) => { return { @@ -58,51 +45,11 @@ export const GenericListEditor = () => { const sdk = useSDK() const cma = useCMA() - const searchValueRef = useRef('') - const [searchValue, setSearchValue] = useState('') - const [listItemResponse, setListItemResponse] = - useState>>() - const [loading, setLoading] = useState(false) - const [page, setPage] = useState(0) - const pageRef = useRef(0) - /** Counter that's simply used to refresh the list when an item gets created */ - const [counter, setCounter] = useState(0) - - const skip = LIST_ITEMS_PER_PAGE * page + const [_, setCounter] = useState(0) const defaultLocale = sdk.locales.default - useDebounce( - async () => { - setLoading(true) - try { - const response = await cma.entry.getMany({ - environmentId: sdk.ids.environment, - spaceId: sdk.ids.space, - query: { - content_type: LIST_ITEM_CONTENT_TYPE_ID, - limit: LIST_ITEMS_PER_PAGE, - skip, - 'fields.internalTitle[match]': searchValue, - 'fields.genericList.sys.id': sdk.entry.getSys().id, - 'sys.archivedAt[exists]': false, - }, - }) - if ( - searchValueRef.current === searchValue && - pageRef.current === page - ) { - setListItemResponse(response) - } - } finally { - setLoading(false) - } - }, - SEARCH_DEBOUNCE_TIME_IN_MS, - [page, searchValue, counter], - ) - const createListItem = async () => { const cardIntro = {} @@ -189,70 +136,14 @@ export const GenericListEditor = () => { - - { - searchValueRef.current = ev.target.value - setSearchValue(ev.target.value) - setPage(0) - pageRef.current = 0 - }} - /> - - - - - - {listItemResponse?.items?.length > 0 && ( - <> - - - {listItemResponse.items.map((item) => ( - { - sdk.navigator - .openEntry(item.sys.id, { - slideIn: { waitForClose: true }, - }) - .then(() => { - setCounter((c) => c + 1) - }) - }} - /> - ))} - - - { - pageRef.current = newPage - setPage(newPage) - }} - /> - - )} - - {listItemResponse?.items?.length === 0 && ( - - No item was found - - )} - + ) } diff --git a/apps/contentful-apps/pages/fields/generic-tag-group-items-field.tsx b/apps/contentful-apps/pages/fields/generic-tag-group-items-field.tsx index 6a577e389b9d..bdd4a8f9a83e 100644 --- a/apps/contentful-apps/pages/fields/generic-tag-group-items-field.tsx +++ b/apps/contentful-apps/pages/fields/generic-tag-group-items-field.tsx @@ -1,37 +1,17 @@ -import { useEffect, useRef, useState } from 'react' -import { useDebounce } from 'react-use' +import { useEffect, useState } from 'react' import type { FieldExtensionSDK } from '@contentful/app-sdk' -import { - Box, - Button, - EntryCard, - Pagination, - Spinner, - Stack, - Text, - TextInput, -} from '@contentful/f36-components' +import { Box, Button } from '@contentful/f36-components' import { PlusIcon } from '@contentful/f36-icons' import { useCMA, useSDK } from '@contentful/react-apps-toolkit' -const LIST_ITEMS_PER_PAGE = 4 -const SEARCH_DEBOUNCE_TIME_IN_MS = 300 +import { EntryListSearch } from '../../components/EntryListSearch' const GenericTagGroupItemsField = () => { - const [page, setPage] = useState(0) - const pageRef = useRef(0) - const [searchValue, setSearchValue] = useState('') - const searchValueRef = useRef('') - const [listItemResponse, setListItemResponse] = useState(null) - const [isLoading, setIsLoading] = useState(false) - - const [counter, setCounter] = useState(0) + const [_, setCounter] = useState(0) const sdk = useSDK() const cma = useCMA() - const skip = LIST_ITEMS_PER_PAGE * page - const createGenericTag = async () => { const tag = await cma.entry.create( { @@ -62,35 +42,6 @@ const GenericTagGroupItemsField = () => { }) } - useDebounce( - async () => { - setIsLoading(true) - try { - const response = await cma.entry.getMany({ - query: { - content_type: 'genericTag', - limit: LIST_ITEMS_PER_PAGE, - skip, - 'fields.internalTitle[match]': searchValue, - 'fields.genericTagGroup.sys.id': sdk.entry.getSys().id, - 'sys.archivedAt[exists]': false, - }, - }) - - if ( - searchValueRef.current === searchValue && - pageRef.current === page - ) { - setListItemResponse(response) - } - } finally { - setIsLoading(false) - } - }, - SEARCH_DEBOUNCE_TIME_IN_MS, - [page, searchValue, counter], - ) - useEffect(() => { sdk.window.startAutoResizer() return () => { @@ -108,71 +59,14 @@ const GenericTagGroupItemsField = () => { - - { - searchValueRef.current = ev.target.value - setSearchValue(ev.target.value) - setPage(0) - pageRef.current = 0 - }} - /> - - - - - - {listItemResponse?.items?.length > 0 && ( - <> - - - {listItemResponse.items.map((item) => ( - { - sdk.navigator - .openEntry(item.sys.id, { - slideIn: { waitForClose: true }, - }) - .then(() => { - setCounter((c) => c + 1) - }) - }} - /> - ))} - - - { - pageRef.current = newPage - setPage(newPage) - }} - /> - - )} - - {listItemResponse?.items?.length === 0 && ( - - No item was found - - )} - + ) } diff --git a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts index 5c76205ba8d7..f73423c0f6d4 100644 --- a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts +++ b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts @@ -731,6 +731,54 @@ describe('getIndictmentInfo', () => { indictmentVerdictAppealDeadlineExpired: false, }) }) + + it('should return correct indictment info when the indictment ruling decision is FINE and the appeal deadline is not expired', () => { + const rulingDate = new Date().toISOString() + const defendants = [ + { + serviceRequirement: ServiceRequirement.NOT_REQUIRED, + verdictViewDate: undefined, + } as Defendant, + ] + + const indictmentInfo = getIndictmentInfo( + CaseIndictmentRulingDecision.FINE, + rulingDate, + defendants, + ) + + expect(indictmentInfo).toEqual({ + indictmentAppealDeadline: new Date( + new Date(rulingDate).setDate(new Date(rulingDate).getDate() + 3), + ).toISOString(), + indictmentVerdictViewedByAll: true, + indictmentVerdictAppealDeadlineExpired: false, + }) + }) + + it('should return correct indictment info when the indictment ruling decision is FINE and the appeal deadline is expired', () => { + const rulingDate = '2024-05-26T21:51:19.156Z' + const defendants = [ + { + serviceRequirement: ServiceRequirement.NOT_REQUIRED, + verdictViewDate: undefined, + } as Defendant, + ] + + const indictmentInfo = getIndictmentInfo( + CaseIndictmentRulingDecision.FINE, + rulingDate, + defendants, + ) + + expect(indictmentInfo).toEqual({ + indictmentAppealDeadline: new Date( + new Date(rulingDate).setDate(new Date(rulingDate).getDate() + 3), + ).toISOString(), + indictmentVerdictViewedByAll: true, + indictmentVerdictAppealDeadlineExpired: true, + }) + }) }) describe('getIndictmentDefendantsInfo', () => { diff --git a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts index bf8139e14014..f01909d284f1 100644 --- a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts +++ b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts @@ -2,6 +2,7 @@ import { CaseAppealDecision, CaseIndictmentRulingDecision, EventType, + FINE_APPEAL_WINDOW_DAYS, getIndictmentVerdictAppealDeadlineStatus, getStatementDeadline, isRequestCase, @@ -151,6 +152,8 @@ export const getIndictmentInfo = ( eventLog?: EventLog[], ): IndictmentInfo => { const indictmentInfo: IndictmentInfo = {} + const isFine = rulingDecision === CaseIndictmentRulingDecision.FINE + const isRuling = rulingDecision === CaseIndictmentRulingDecision.RULING if (!rulingDate) { return indictmentInfo @@ -158,13 +161,16 @@ export const getIndictmentInfo = ( const theRulingDate = new Date(rulingDate) indictmentInfo.indictmentAppealDeadline = new Date( - theRulingDate.getTime() + getDays(VERDICT_APPEAL_WINDOW_DAYS), + theRulingDate.getTime() + + getDays(isFine ? FINE_APPEAL_WINDOW_DAYS : VERDICT_APPEAL_WINDOW_DAYS), ).toISOString() const verdictInfo = defendants?.map<[boolean, Date | undefined]>( (defendant) => [ - rulingDecision === CaseIndictmentRulingDecision.RULING, - defendant.serviceRequirement === ServiceRequirement.NOT_REQUIRED + isRuling || isFine, + isFine + ? theRulingDate + : defendant.serviceRequirement === ServiceRequirement.NOT_REQUIRED ? new Date() : defendant.verdictViewDate ? new Date(defendant.verdictViewDate) @@ -173,7 +179,7 @@ export const getIndictmentInfo = ( ) const [indictmentVerdictViewedByAll, indictmentVerdictAppealDeadlineExpired] = - getIndictmentVerdictAppealDeadlineStatus(verdictInfo) + getIndictmentVerdictAppealDeadlineStatus(verdictInfo, isFine) indictmentInfo.indictmentVerdictViewedByAll = indictmentVerdictViewedByAll indictmentInfo.indictmentVerdictAppealDeadlineExpired = indictmentVerdictAppealDeadlineExpired @@ -189,6 +195,8 @@ export const getIndictmentDefendantsInfo = (theCase: Case) => { return theCase.defendants?.map((defendant) => { const serviceRequired = defendant.serviceRequirement === ServiceRequirement.REQUIRED + const isFine = + theCase.indictmentRulingDecision === CaseIndictmentRulingDecision.FINE const { verdictViewDate } = defendant @@ -196,7 +204,10 @@ export const getIndictmentDefendantsInfo = (theCase: Case) => { const verdictAppealDeadline = baseDate ? new Date( - new Date(baseDate).getTime() + getDays(VERDICT_APPEAL_WINDOW_DAYS), + new Date(baseDate).getTime() + + getDays( + isFine ? FINE_APPEAL_WINDOW_DAYS : VERDICT_APPEAL_WINDOW_DAYS, + ), ).toISOString() : undefined diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts b/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts index e4200b4dd5ee..36dbfdc8671c 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts @@ -277,7 +277,9 @@ const canPrisonAdminUserAccessCase = ( // Check case indictment ruling decision access if ( - theCase.indictmentRulingDecision !== CaseIndictmentRulingDecision.RULING + theCase.indictmentRulingDecision !== + CaseIndictmentRulingDecision.RULING && + theCase.indictmentRulingDecision !== CaseIndictmentRulingDecision.FINE ) { return false } diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts b/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts index 34ae100c239d..bc5f2f9f3a5f 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts @@ -211,7 +211,12 @@ const getPrisonAdminUserCasesQueryFilter = (): WhereOptions => { { type: indictmentCases, state: CaseState.COMPLETED, - indictment_ruling_decision: CaseIndictmentRulingDecision.RULING, + indictment_ruling_decision: { + [Op.or]: [ + CaseIndictmentRulingDecision.RULING, + CaseIndictmentRulingDecision.FINE, + ], + }, indictment_review_decision: IndictmentCaseReviewDecision.ACCEPT, id: { [Op.in]: Sequelize.literal(` diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts index cb6a69ebaf7c..4d6027e5d5b4 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts @@ -388,7 +388,12 @@ describe('getCasesQueryFilter', () => { { type: indictmentCases, state: CaseState.COMPLETED, - indictment_ruling_decision: CaseIndictmentRulingDecision.RULING, + indictment_ruling_decision: { + [Op.or]: [ + CaseIndictmentRulingDecision.RULING, + CaseIndictmentRulingDecision.FINE, + ], + }, indictment_review_decision: IndictmentCaseReviewDecision.ACCEPT, id: { [Op.in]: Sequelize.literal(` diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts index 570de65e3f81..9d147a4ca169 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts @@ -132,6 +132,7 @@ describe.each(prisonSystemRoles)('prison admin user %s', (role) => { (state) => { const accessibleCaseIndictmentRulingDecisions = [ CaseIndictmentRulingDecision.RULING, + CaseIndictmentRulingDecision.FINE, ] describe.each( diff --git a/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.spec.tsx b/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.spec.tsx new file mode 100644 index 000000000000..48ac69307bbc --- /dev/null +++ b/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.spec.tsx @@ -0,0 +1,80 @@ +import faker from 'faker' +import { render, screen } from '@testing-library/react' + +import { + CaseIndictmentRulingDecision, + CaseType, + Defendant, +} from '../../graphql/schema' +import { mockCase } from '../../utils/mocks' +import { + ApolloProviderWrapper, + FormContextWrapper, + IntlProviderWrapper, +} from '../../utils/testHelpers' +import BlueBoxWithDate from './BlueBoxWithDate' + +jest.mock('next/router', () => ({ + useRouter() { + return { + pathname: '', + query: { + id: 'test_id', + }, + } + }, +})) + +describe('BlueBoxWithDate', () => { + const name = faker.name.firstName() + const rulingDate = new Date().toISOString() + + const mockDefendant: Defendant = { + name, + id: faker.datatype.uuid(), + } + + it('renders correctly when ruling decision is FINE', () => { + render( + + + + + + + , + ) + + expect(screen.getByText('Viðurlagaákvörðun')).toBeInTheDocument() + expect(screen.getByText(name)).toBeInTheDocument() + }) + + it('renders correctly when ruling decision is RULING', () => { + render( + + + + + + + , + ) + + expect(screen.getByText('Birting dóms')).toBeInTheDocument() + expect(screen.getByText(name)).toBeInTheDocument() + }) +}) diff --git a/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.strings.ts b/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.strings.ts index a68972df8229..a3f57cbebd4c 100644 --- a/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.strings.ts +++ b/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.strings.ts @@ -70,4 +70,17 @@ export const strings = defineMessages({ description: 'Notaður sem texti í valmynd fyrir aðgerðina að senda mál til fullnustu', }, + indictmentRulingDecisionFine: { + id: 'judicial.system.core:blue_box_with_date.indictment_ruling_decision_fine', + defaultMessage: 'Viðurlagaákvörðun', + description: + 'Notaður sem titill í svæði þar sem kærufrestur viðurlagaákvörðunar er tekinn fram', + }, + fineAppealDeadline: { + id: 'judicial.system.core:blue_box_with_date.fine_appeal_deadline', + defaultMessage: + 'Kærufrestur Ríkissaksóknara {appealDeadlineIsInThePast, select, true {var} other {er}} til {appealDeadline}', + description: + 'Notaður sem titill í svæði þar sem kærufrestur viðurlagaákvörðunar er tekinn fram', + }, }) diff --git a/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.tsx b/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.tsx index 6a404390e8c9..50ecd41a0c44 100644 --- a/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.tsx +++ b/apps/judicial-system/web/src/components/BlueBoxWithIcon/BlueBoxWithDate.tsx @@ -18,8 +18,8 @@ import { VERDICT_APPEAL_WINDOW_DAYS } from '@island.is/judicial-system/types' import { errors } from '@island.is/judicial-system-web/messages' import { + CaseIndictmentRulingDecision, Defendant, - IndictmentCaseReviewDecision, ServiceRequirement, } from '../../graphql/schema' import { formatDateForServer, useDefendants } from '../../utils/hooks' @@ -32,12 +32,11 @@ import * as styles from './BlueBoxWithIcon.css' interface Props { defendant: Defendant - indictmentReviewDecision?: IndictmentCaseReviewDecision | null icon?: IconMapIcon } const BlueBoxWithDate: FC = (props) => { - const { defendant, indictmentReviewDecision, icon } = props + const { defendant, icon } = props const { formatMessage } = useIntl() const [dates, setDates] = useState<{ verdictViewDate?: Date @@ -52,6 +51,9 @@ const BlueBoxWithDate: FC = (props) => { const { workingCase, setWorkingCase } = useContext(FormContext) const router = useRouter() + const isFine = + workingCase.indictmentRulingDecision === CaseIndictmentRulingDecision.FINE + const serviceRequired = defendant.serviceRequirement === ServiceRequirement.REQUIRED @@ -133,34 +135,45 @@ const BlueBoxWithDate: FC = (props) => { const textItems = useMemo(() => { const texts = [] - if (serviceRequired) { + if (isFine) { texts.push( - formatMessage(strings.defendantVerdictViewedDate, { - date: formatDate(dates.verdictViewDate ?? defendant.verdictViewDate), + formatMessage(strings.fineAppealDeadline, { + appealDeadlineIsInThePast: defendant.isVerdictAppealDeadlineExpired, + appealDeadline: formatDate(defendant.verdictAppealDeadline), }), ) - } - - texts.push( - formatMessage(appealExpirationInfo.message, { - appealExpirationDate: appealExpirationInfo.date, - }), - ) + } else { + if (serviceRequired) { + texts.push( + formatMessage(strings.defendantVerdictViewedDate, { + date: formatDate( + dates.verdictViewDate ?? defendant.verdictViewDate, + ), + }), + ) + } - if (defendant.verdictAppealDate) { texts.push( - formatMessage(strings.defendantAppealDate, { - date: formatDate(defendant.verdictAppealDate), + formatMessage(appealExpirationInfo.message, { + appealExpirationDate: appealExpirationInfo.date, }), ) - } - if (defendant.sentToPrisonAdminDate && defendant.isSentToPrisonAdmin) { - texts.push( - formatMessage(strings.sendToPrisonAdminDate, { - date: formatDate(defendant.sentToPrisonAdminDate), - }), - ) + if (defendant.verdictAppealDate) { + texts.push( + formatMessage(strings.defendantAppealDate, { + date: formatDate(defendant.verdictAppealDate), + }), + ) + } + + if (defendant.sentToPrisonAdminDate && defendant.isSentToPrisonAdmin) { + texts.push( + formatMessage(strings.sendToPrisonAdminDate, { + date: formatDate(defendant.sentToPrisonAdminDate), + }), + ) + } } return texts @@ -169,10 +182,13 @@ const BlueBoxWithDate: FC = (props) => { appealExpirationInfo.message, dates.verdictViewDate, defendant.isSentToPrisonAdmin, + defendant.isVerdictAppealDeadlineExpired, defendant.sentToPrisonAdminDate, defendant.verdictAppealDate, + defendant.verdictAppealDeadline, defendant.verdictViewDate, formatMessage, + isFine, serviceRequired, ]) @@ -205,7 +221,9 @@ const BlueBoxWithDate: FC = (props) => { @@ -217,7 +235,7 @@ const BlueBoxWithDate: FC = (props) => { - {(!serviceRequired || defendant.verdictViewDate) && + {(!serviceRequired || defendant.verdictViewDate || isFine) && textItems.map((text, index) => ( = (props) => { {defendant.verdictAppealDate || - defendant.isVerdictAppealDeadlineExpired ? null : !serviceRequired || - defendant.verdictViewDate ? ( + defendant.isVerdictAppealDeadlineExpired || + isFine ? null : !serviceRequired || defendant.verdictViewDate ? ( = (props) => { variant="text" onClick={handleSendToPrisonAdmin} size="small" - disabled={!indictmentReviewDecision || !defendant.verdictViewDate} + disabled={ + !workingCase.indictmentReviewDecision || + (!isFine && !defendant.verdictViewDate) + } > {formatMessage(strings.sendToPrisonAdmin)} diff --git a/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.strings.ts b/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.strings.ts index 57689f0fa174..1cff30e2a52b 100644 --- a/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.strings.ts +++ b/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.strings.ts @@ -28,14 +28,16 @@ export const strings = defineMessages({ description: 'Notaður sem titill á "ákvörðun" hluta af yfirliti ákæru.', }, reviewTagAppealed: { - id: 'judicial.system.core:info_card_indictment.review_tag_appealed_v1', - defaultMessage: 'Áfrýja dómi', + id: 'judicial.system.core:info_card_indictment.review_tag_appealed_v3', + defaultMessage: + '{isFine, select, true {Kæra viðurlagaákvörðun} other {Áfrýja dómi}}', description: 'Notað sem texti á tagg fyrir "Áfrýjun" tillögu í yfirliti ákæru.', }, reviewTagAccepted: { - id: 'judicial.system.core:info_card_indictment.review_tag_completed_v1', - defaultMessage: 'Una dómi', + id: 'judicial.system.core:info_card_indictment.review_tag_completed_v2', + defaultMessage: + 'Una {isFine, select, true {viðurlagaákvörðun} other {dómi}}', description: 'Notað sem texti á tagg fyrir "Una" tillögu í yfirliti ákæru.', }, indictmentReviewedDateTitle: { diff --git a/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.tsx b/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.tsx index 1a0c7ccf70c3..a2112beb8c66 100644 --- a/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.tsx +++ b/apps/judicial-system/web/src/components/InfoCard/useInfoCardItems.tsx @@ -15,6 +15,7 @@ import { core } from '@island.is/judicial-system-web/messages' import { requestCourtDate } from '@island.is/judicial-system-web/messages' import { Case, + CaseIndictmentRulingDecision, CaseType, IndictmentCaseReviewDecision, } from '@island.is/judicial-system-web/src/graphql/schema' @@ -279,6 +280,11 @@ const useInfoCardItems = () => { IndictmentCaseReviewDecision.ACCEPT ? strings.reviewTagAccepted : strings.reviewTagAppealed, + { + isFine: + workingCase.indictmentRulingDecision === + CaseIndictmentRulingDecision.FINE, + }, ), ], } diff --git a/apps/judicial-system/web/src/components/index.ts b/apps/judicial-system/web/src/components/index.ts index adf54284e0c6..5dc0a2ff8240 100644 --- a/apps/judicial-system/web/src/components/index.ts +++ b/apps/judicial-system/web/src/components/index.ts @@ -1,6 +1,7 @@ export { CourtCaseInfo, ProsecutorCaseInfo } from './CaseInfo/CaseInfo' export { default as AccordionListItem } from './AccordionListItem/AccordionListItem' export { default as BlueBox } from './BlueBox/BlueBox' +export { default as BlueBoxWithDate } from './BlueBoxWithIcon/BlueBoxWithDate' export { default as CaseDates } from './CaseDates/CaseDates' export { default as CaseFile } from './CaseFile/CaseFile' export { default as CaseFileList } from './CaseFileList/CaseFileList' diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.strings.ts b/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.strings.ts index bda2b5b7b75f..3418534bf794 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.strings.ts +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.strings.ts @@ -22,9 +22,9 @@ export const strings = defineMessages({ description: 'Notaður sem titill á yfirliti ákæru.', }, reviewerSubtitle: { - id: 'judicial.system.core:public_prosecutor.indictments.overview.reviewer_subtitle', + id: 'judicial.system.core:public_prosecutor.indictments.overview.reviewer_subtitle_v2', defaultMessage: - 'Frestur til að áfrýja dómi rennur út {indictmentAppealDeadline}', + 'Frestur til að {isFine, select, true {kæra viðurlagaákvörðun} other {áfrýja dómi}} {appealDeadlineIsInThePast, select, true {rann} other {rennur}} út {indictmentAppealDeadline}', description: 'Notaður sem undirtitill á yfirliti ákæru.', }, reviewerAssignedModalTitle: { diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx b/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx index 6ac0b378751e..36abf477db77 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx @@ -8,6 +8,7 @@ import { formatDate } from '@island.is/judicial-system/formatters' import { core, titles } from '@island.is/judicial-system-web/messages' import { BlueBox, + BlueBoxWithDate, CourtCaseInfo, FormContentContainer, FormContext, @@ -23,8 +24,8 @@ import { // useIndictmentsLawsBroken, NOTE: Temporarily hidden while list of laws broken is not complete UserContext, } from '@island.is/judicial-system-web/src/components' -import BlueBoxWithDate from '@island.is/judicial-system-web/src/components/BlueBoxWithIcon/BlueBoxWithDate' import { useProsecutorSelectionUsersQuery } from '@island.is/judicial-system-web/src/components/ProsecutorSelection/prosecutorSelectionUsers.generated' +import { CaseIndictmentRulingDecision } from '@island.is/judicial-system-web/src/graphql/schema' import { useCase } from '@island.is/judicial-system-web/src/utils/hooks' import { strings } from './Overview.strings' @@ -102,11 +103,7 @@ export const Overview = () => { {workingCase.defendants?.map((defendant) => ( - + ))} @@ -130,9 +127,14 @@ export const Overview = () => { description={ {fm(strings.reviewerSubtitle, { + isFine: + workingCase.indictmentRulingDecision === + CaseIndictmentRulingDecision.FINE, indictmentAppealDeadline: formatDate( workingCase.indictmentAppealDeadline, ), + appealDeadlineIsInThePast: + workingCase.indictmentVerdictAppealDeadlineExpired, })} } diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.strings.ts b/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.strings.ts index 85cb3bb24b15..130b6c5b87da 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.strings.ts +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.strings.ts @@ -18,6 +18,12 @@ export const strings = defineMessages({ description: 'Notað sem texti á tagg fyrir "Unun" tillögu í yfirlesin mál málalista', }, + reviewTagFineAppealed: { + id: 'judicial.system.core:public_prosecutor.tables.cases_reviewed.review_tag_fine_appealed', + defaultMessage: 'Kært', + description: + 'Notað sem texti á tagg fyrir "Kært" tillögu í yfirlesin mál málalista', + }, infoContainerMessage: { id: 'judicial.system.core:public_prosecutor.tables.cases_reviewed.info_container_message', defaultMessage: 'Engin yfirlesin mál.', diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx b/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx index e6a4b98ab6c2..3462a41a096e 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx @@ -4,6 +4,7 @@ import { AnimatePresence } from 'framer-motion' import { Tag, Text } from '@island.is/island-ui/core' import { capitalize } from '@island.is/judicial-system/formatters' +import { CaseIndictmentRulingDecision } from '@island.is/judicial-system/types' import { core, tables } from '@island.is/judicial-system-web/messages' import { SectionHeading } from '@island.is/judicial-system-web/src/components' import { useContextMenu } from '@island.is/judicial-system-web/src/components/ContextMenu/ContextMenu' @@ -31,13 +32,19 @@ const CasesReviewed: FC = ({ loading, cases }) => { const { formatMessage } = useIntl() const { openCaseInNewTabMenuItem } = useContextMenu() - const decisionMapping = { - [IndictmentCaseReviewDecision.ACCEPT]: formatMessage( - strings.reviewTagAccepted, - ), - [IndictmentCaseReviewDecision.APPEAL]: formatMessage( - strings.reviewTagAppealed, - ), + const indictmentReviewDecisionMapping = ( + reviewDecision: IndictmentCaseReviewDecision, + isFine: boolean, + ) => { + if (reviewDecision === IndictmentCaseReviewDecision.ACCEPT) { + return formatMessage(strings.reviewTagAccepted) + } else if (reviewDecision === IndictmentCaseReviewDecision.APPEAL) { + return formatMessage( + isFine ? strings.reviewTagFineAppealed : strings.reviewTagAppealed, + ) + } else { + return null + } } const getVerdictViewTag = (row: CaseListEntry) => { @@ -49,7 +56,9 @@ const CasesReviewed: FC = ({ loading, cases }) => { row.defendants.some((defendant) => defendant.isSentToPrisonAdmin), ) - if (someDefendantIsSentToPrisonAdmin) { + if (row.indictmentRulingDecision === CaseIndictmentRulingDecision.FINE) { + return null + } else if (someDefendantIsSentToPrisonAdmin) { variant = 'red' message = strings.tagVerdictViewSentToPrisonAdmin } else if (!row.indictmentVerdictViewedByAll) { @@ -112,7 +121,11 @@ const CasesReviewed: FC = ({ loading, cases }) => { cell: (row) => ( {row.indictmentReviewDecision && - decisionMapping[row.indictmentReviewDecision]} + indictmentReviewDecisionMapping( + row.indictmentReviewDecision, + row.indictmentRulingDecision === + CaseIndictmentRulingDecision.FINE, + )} ), }, diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.css.ts b/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.css.ts index f3b85cfcbefe..f0211d02ca05 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.css.ts +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.css.ts @@ -4,7 +4,12 @@ import { theme } from '@island.is/island-ui/theme' export const gridRow = style({ display: 'grid', - gridTemplateColumns: '1.6fr 1fr', + gridTemplateColumns: '1fr auto', gridGap: theme.spacing[1], - marginBottom: theme.spacing[1], + + '@media': { + [`screen and (max-width: ${theme.breakpoints.lg}px)`]: { + gridTemplateColumns: '1fr', + }, + }, }) diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.strings.ts b/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.strings.ts index 796a7372f889..c9250aa3fd48 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.strings.ts +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.strings.ts @@ -2,14 +2,14 @@ import { defineMessages } from 'react-intl' export const strings = defineMessages({ title: { - id: 'judicial.system.core:public_prosecutor.indictments.review_decision.title', - defaultMessage: 'Ákvörðun um áfrýjun', + id: 'judicial.system.core:public_prosecutor.indictments.review_decision.title_v1', + defaultMessage: 'Ákvörðun um {isFine, select, true {kæru} other {áfrýjun}}', description: 'Notaður sem titill á ákvörðum um áfrýjun boxi fyrir ákæru.', }, subtitle: { - id: 'judicial.system.core:public_prosecutor.indictments.review_decision.subtitle', + id: 'judicial.system.core:public_prosecutor.indictments.review_decision.subtitle_v1', defaultMessage: - 'Frestur til að áfrýja dómi rennur út {indictmentAppealDeadline}', + 'Frestur til að {isFine, select, true {kæra viðurlagaákvörðun} other {áfrýja dómi}} {appealDeadlineIsInThePast, select, true {rann} other {rennur}} út {indictmentAppealDeadline}', description: 'Notaður sem undirtitill á ákvörðum um áfrýjun boxi fyrir ákæru.', }, @@ -24,6 +24,18 @@ export const strings = defineMessages({ defaultMessage: 'Una héraðsdómi', description: 'Notaður sem texti fyrir "Una héraðsdómi" radio takka.', }, + appealFineToCourtOfAppeals: { + id: 'judicial.system.core:public_prosecutor.indictments.review_decision.appeal_fine_to_court_of_appeals', + defaultMessage: 'Kæra viðurlagaákvörðun til Landsréttar', + description: + 'Notaður sem texti fyrir "Kæra viðurlagaákvörðun til Landsréttar" radio takka.', + }, + acceptFineDecision: { + id: 'judicial.system.core:public_prosecutor.indictments.review_decision.accept_fine_decision', + defaultMessage: 'Una viðurlagaákvörðun', + description: + 'Notaður sem texti fyrir "Kæra viðurlagaákvörðun" radio takka.', + }, reviewModalTitle: { id: 'judicial.system.core:indictments_review.title', defaultMessage: 'Staðfesta ákvörðun', @@ -31,11 +43,16 @@ export const strings = defineMessages({ }, reviewModalText: { id: 'judicial.system.core:indictments_review.modal_text', - defaultMessage: 'Ertu viss um að þú viljir {reviewerDecision, select, ACCEPT {una héraðsdómi} APPEAL {áfrýja héraðsdómi til Landsréttar} other {halda áfram}}?', description: 'Notaður sem texti í yfirlitsglugga um yfirlit ákæru.', }, + reviewModalTextFine: { + id: 'judicial.system.core:indictments_review.modal_text_fine', + defaultMessage: + 'Ertu viss um að þú viljir {reviewerDecision, select, ACCEPT {una viðurlagaákvörðun} APPEAL {kæra viðurlagaákvörðun til Landsréttar} other {halda áfram}}?', + description: 'Notaður sem texti í yfirlitsglugga um yfirlit ákæru.', + }, reviewModalPrimaryButtonText: { id: 'judicial.system.core:indictments_review.modal_primary_button_text', defaultMessage: 'Staðfesta', diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.tsx b/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.tsx index ff25436bea93..119153159b66 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.tsx +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/components/ReviewDecision/ReviewDecision.tsx @@ -21,25 +21,28 @@ import * as styles from './ReviewDecision.css' interface Props { caseId: string indictmentAppealDeadline?: string + indictmentAppealDeadlineIsInThePast?: boolean modalVisible?: boolean setModalVisible: Dispatch> + isFine: boolean onSelect?: () => void } export const ReviewDecision: FC = (props) => { - const { user } = useContext(UserContext) - const router = useRouter() - const { formatMessage: fm } = useIntl() - const { updateCase } = useCase() - const { caseId, indictmentAppealDeadline, + indictmentAppealDeadlineIsInThePast, modalVisible, setModalVisible, + isFine, onSelect, } = props + const { user } = useContext(UserContext) + const router = useRouter() + const { formatMessage: fm } = useIntl() + const { updateCase } = useCase() const [indictmentReviewDecision, setIndictmentReviewDecision] = useState< IndictmentCaseReviewDecision | undefined >(undefined) @@ -58,11 +61,15 @@ export const ReviewDecision: FC = (props) => { const options = [ { - label: fm(strings.appealToCourtOfAppeals), + label: fm( + isFine + ? strings.appealFineToCourtOfAppeals + : strings.appealToCourtOfAppeals, + ), value: IndictmentCaseReviewDecision.APPEAL, }, { - label: fm(strings.acceptDecision), + label: fm(isFine ? strings.acceptFineDecision : strings.acceptDecision), value: IndictmentCaseReviewDecision.ACCEPT, }, ] @@ -74,11 +81,13 @@ export const ReviewDecision: FC = (props) => { return ( {fm(strings.subtitle, { + isFine, indictmentAppealDeadline: formatDate(indictmentAppealDeadline), + appealDeadlineIsInThePast: indictmentAppealDeadlineIsInThePast, })} } @@ -107,9 +116,12 @@ export const ReviewDecision: FC = (props) => { {modalVisible && ( setModalVisible(false)} diff --git a/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx b/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx index 83b140c09ed3..d5d2a465c780 100644 --- a/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx +++ b/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx @@ -285,8 +285,15 @@ const IndictmentOverview: FC = () => { indictmentAppealDeadline={ workingCase.indictmentAppealDeadline ?? '' } + indictmentAppealDeadlineIsInThePast={ + workingCase.indictmentVerdictAppealDeadlineExpired ?? false + } modalVisible={modalVisible} setModalVisible={setModalVisible} + isFine={ + workingCase.indictmentRulingDecision === + CaseIndictmentRulingDecision.FINE + } onSelect={() => setIsReviewDecisionSelected(true)} /> )} diff --git a/apps/services/auth/admin-api/infra/auth-admin-api.ts b/apps/services/auth/admin-api/infra/auth-admin-api.ts index 0fc25a62d5d4..cf773f0dbd94 100644 --- a/apps/services/auth/admin-api/infra/auth-admin-api.ts +++ b/apps/services/auth/admin-api/infra/auth-admin-api.ts @@ -4,7 +4,12 @@ import { service, ServiceBuilder, } from '../../../../../infra/src/dsl/dsl' -import { Base, Client, RskProcuring } from '../../../../../infra/src/dsl/xroad' +import { + Base, + Client, + NationalRegistryAuthB2C, + RskProcuring, +} from '../../../../../infra/src/dsl/xroad' const REDIS_NODE_CONFIG = { dev: json([ @@ -84,8 +89,10 @@ export const serviceSetup = (): ServiceBuilder<'services-auth-admin-api'> => { '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET', SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME', SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD', + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: + '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET', }) - .xroad(Base, Client, RskProcuring) + .xroad(Base, Client, RskProcuring, NationalRegistryAuthB2C) .ingress({ primary: { host: { diff --git a/apps/services/auth/admin-api/src/app/app.module.ts b/apps/services/auth/admin-api/src/app/app.module.ts index dc56c6fe0343..5f947672c654 100644 --- a/apps/services/auth/admin-api/src/app/app.module.ts +++ b/apps/services/auth/admin-api/src/app/app.module.ts @@ -9,8 +9,10 @@ import { import { AuthModule } from '@island.is/auth-nest-tools' import { RskRelationshipsClientConfig } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientConfig } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-registry-v3' import { CompanyRegistryConfig } from '@island.is/clients/rsk/company-registry' import { SyslumennClientConfig } from '@island.is/clients/syslumenn' +import { ZendeskServiceConfig } from '@island.is/clients/zendesk' import { AuditModule } from '@island.is/nest/audit' import { IdsClientConfig, XRoadConfig } from '@island.is/nest/config' import { FeatureFlagConfig } from '@island.is/nest/feature-flags' @@ -31,7 +33,6 @@ import { ProvidersModule } from './v2/providers/providers.module' import { ScopesModule } from './v2/scopes/scopes.module' import { ClientSecretsModule } from './v2/secrets/client-secrets.module' import { TenantsModule } from './v2/tenants/tenants.module' -import { ZendeskServiceConfig } from '@island.is/clients/zendesk' @Module({ imports: [ @@ -61,6 +62,7 @@ import { ZendeskServiceConfig } from '@island.is/clients/zendesk' DelegationConfig, RskRelationshipsClientConfig, NationalRegistryClientConfig, + NationalRegistryV3ClientConfig, CompanyRegistryConfig, FeatureFlagConfig, XRoadConfig, diff --git a/apps/services/auth/delegation-api/src/app/app.module.ts b/apps/services/auth/delegation-api/src/app/app.module.ts index ac10af9d95f1..71b347827378 100644 --- a/apps/services/auth/delegation-api/src/app/app.module.ts +++ b/apps/services/auth/delegation-api/src/app/app.module.ts @@ -9,6 +9,7 @@ import { import { AuthModule } from '@island.is/auth-nest-tools' import { RskRelationshipsClientConfig } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientConfig } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-registry-v3' import { CompanyRegistryConfig } from '@island.is/clients/rsk/company-registry' import { SyslumennClientConfig } from '@island.is/clients/syslumenn' import { AuditModule } from '@island.is/nest/audit' @@ -48,6 +49,7 @@ import { ScopesModule } from './scopes/scopes.module' FeatureFlagConfig, IdsClientConfig, NationalRegistryClientConfig, + NationalRegistryV3ClientConfig, RskRelationshipsClientConfig, CompanyRegistryConfig, XRoadConfig, diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-incoming.spec.ts b/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-incoming.spec.ts index 6f5c526d1188..377a7771410b 100644 --- a/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-incoming.spec.ts +++ b/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-incoming.spec.ts @@ -8,9 +8,11 @@ import { Delegation, DelegationScope, DelegationsIndexService, + NationalRegistryV3FeatureService, } from '@island.is/auth-api-lib' import { RskRelationshipsClient } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3' import { CompanyRegistryClientService } from '@island.is/clients/rsk/company-registry' import { expectMatchingDelegations, @@ -23,189 +25,219 @@ import { accessIncomingTestCases } from '../../../../test/access-incoming-test-c import { setupWithAuth } from '../../../../test/setup' import { filterExpectedDelegations } from './utils' +const fromName: string = faker.name.findName() + describe('MeDelegationsController', () => { - describe.each(Object.keys(accessIncomingTestCases))( - 'Incoming Access with test case: %s', - (caseName) => { - const testCase = accessIncomingTestCases[caseName] - let app: TestApp - let server: request.SuperTest - let factory: FixtureFactory - let delegations: Delegation[] = [] - const fromName = faker.name.findName() - let delegationIndexService: DelegationsIndexService - - beforeAll(async () => { - // Arrange - app = await setupWithAuth({ - user: testCase.user, - customScopeRules: testCase.customScopeRules, - }) - server = request(app.getHttpServer()) - delegationIndexService = app.get(DelegationsIndexService) - const rskRelationshipsClientService = app.get(RskRelationshipsClient) - const nationalRegistryClientService = app.get( - NationalRegistryClientService, - ) - const companyRegistryClientService = app.get( - CompanyRegistryClientService, - ) - jest - .spyOn(nationalRegistryClientService, 'getCustodyChildren') - .mockImplementation(async () => []) - jest - .spyOn(nationalRegistryClientService, 'getIndividual') - .mockImplementation(async (nationalId: string) => - createNationalRegistryUser({ - nationalId, - name: fromName, - }), - ) - jest - .spyOn(rskRelationshipsClientService, 'getIndividualRelationships') - .mockImplementation(async () => null) - jest - .spyOn(companyRegistryClientService, 'getCompany') - .mockImplementation(async () => null) - jest - .spyOn(delegationIndexService, 'indexDelegations') - .mockImplementation() - jest - .spyOn(delegationIndexService, 'indexCustomDelegations') - .mockImplementation() - - factory = new FixtureFactory(app) - await Promise.all( - testCase.domains.map((domain) => factory.createDomain(domain)), - ) - await Promise.all( - (testCase.accessTo ?? []).map((scope) => - factory.createApiScopeUserAccess({ - nationalId: testCase.user.nationalId, - scope, - }), - ), - ) - }) - - beforeEach(async () => { - if (delegations) { - await Delegation.destroy({ - where: { - id: { [Op.in]: delegations.map((delegation) => delegation.id) }, - }, + describe.each([false, true])( + 'national registry v3 featureflag: %s', + (featureFlag) => { + describe.each(Object.keys(accessIncomingTestCases))( + 'Incoming Access with test case: %s', + (caseName) => { + const testCase = accessIncomingTestCases[caseName] + let app: TestApp + let server: request.SuperTest + let factory: FixtureFactory + let delegations: Delegation[] = [] + let delegationIndexService: DelegationsIndexService + + beforeAll(async () => { + // Arrange + app = await setupWithAuth({ + user: testCase.user, + customScopeRules: testCase.customScopeRules, + }) + server = request(app.getHttpServer()) + delegationIndexService = app.get(DelegationsIndexService) + const rskRelationshipsClientService = app.get( + RskRelationshipsClient, + ) + const nationalRegistryClientService = app.get( + NationalRegistryClientService, + ) + const nationalRegistryV3ClientService = app.get( + NationalRegistryV3ClientService, + ) + const companyRegistryClientService = app.get( + CompanyRegistryClientService, + ) + const nationalRegistryV3FeatureService = app.get( + NationalRegistryV3FeatureService, + ) + jest + .spyOn(nationalRegistryClientService, 'getCustodyChildren') + .mockImplementation(async () => []) + jest + .spyOn(nationalRegistryClientService, 'getIndividual') + .mockImplementation(async (nationalId: string) => + createNationalRegistryUser({ + nationalId, + name: fromName, + }), + ) + jest + .spyOn(nationalRegistryV3ClientService, 'getAllDataIndividual') + .mockImplementation(async (nationalId: string) => { + return { kennitala: nationalId, nafn: fromName } + }) + jest + .spyOn( + rskRelationshipsClientService, + 'getIndividualRelationships', + ) + .mockImplementation(async () => null) + jest + .spyOn(companyRegistryClientService, 'getCompany') + .mockImplementation(async () => null) + jest + .spyOn(delegationIndexService, 'indexDelegations') + .mockImplementation() + jest + .spyOn(delegationIndexService, 'indexCustomDelegations') + .mockImplementation() + jest + .spyOn(nationalRegistryV3FeatureService, 'getValue') + .mockImplementation(async () => featureFlag) + factory = new FixtureFactory(app) + await Promise.all( + testCase.domains.map((domain) => factory.createDomain(domain)), + ) + await Promise.all( + (testCase.accessTo ?? []).map((scope) => + factory.createApiScopeUserAccess({ + nationalId: testCase.user.nationalId, + scope, + }), + ), + ) + }) + + beforeEach(async () => { + if (delegations) { + await Delegation.destroy({ + where: { + id: { + [Op.in]: delegations.map((delegation) => delegation.id), + }, + }, + }) + } + + delegations = await Promise.all( + (testCase.delegations ?? []).map((delegation) => + factory.createCustomDelegation({ + toNationalId: testCase.user.nationalId, + fromName, + ...delegation, + }), + ), + ) }) - } - - delegations = await Promise.all( - (testCase.delegations ?? []).map((delegation) => - factory.createCustomDelegation({ - toNationalId: testCase.user.nationalId, - fromName, - ...delegation, - }), - ), - ) - }) - - it('GET /v1/me/delegations?direction=incoming filters delegations', async () => { - // Arrange - const expectedDelegations = filterExpectedDelegations( - delegations, - testCase.expected, - ) - - // Act - const res = await server.get('/v1/me/delegations?direction=incoming') - - // Assert - expect(delegationIndexService.indexDelegations).toHaveBeenCalled() - expect(res.status).toEqual(200) - expectMatchingDelegations(res.body, expectedDelegations) - }) - - if (testCase.expected.length > 0) { - it.each(testCase.expected)( - 'GET /v1/me/delegation/:id finds delegation in domain $name', - async (domain) => { + + it('GET /v1/me/delegations?direction=incoming filters delegations', async () => { // Arrange - const expectedDelegation = filterExpectedDelegations( + const expectedDelegations = filterExpectedDelegations( delegations, testCase.expected, - ).find((delegation) => delegation.domainName === domain.name) - assert(expectedDelegation) + ) // Act const res = await server.get( - `/v1/me/delegations/${expectedDelegation.id}`, + '/v1/me/delegations?direction=incoming', ) // Assert + expect(delegationIndexService.indexDelegations).toHaveBeenCalled() expect(res.status).toEqual(200) - expectMatchingDelegations(res.body, expectedDelegation) - }, - ) - - it.each(testCase.expected)( - 'DELETE /v1/me/delegations/:id removes access to delegation', - async (domain) => { - // Arrange - const delegationScopeModel = app.get( - getModelToken(DelegationScope), - ) - const delegationModel = app.get( - getModelToken(Delegation), - ) - const delegation = delegations.find( - (delegation) => - delegation.domainName === domain.name && - delegation.toNationalId === testCase.user.nationalId, - ) - assert(delegation) + expectMatchingDelegations(res.body, expectedDelegations) + }) - // Act - const res = await server.delete( - `/v1/me/delegations/${delegation.id}`, + if (testCase.expected.length > 0) { + it.each(testCase.expected)( + 'GET /v1/me/delegation/:id finds delegation in domain $name', + async (domain) => { + // Arrange + const expectedDelegation = filterExpectedDelegations( + delegations, + testCase.expected, + ).find((delegation) => delegation.domainName === domain.name) + assert(expectedDelegation) + + // Act + const res = await server.get( + `/v1/me/delegations/${expectedDelegation.id}`, + ) + + // Assert + expect(res.status).toEqual(200) + expectMatchingDelegations(res.body, expectedDelegation) + }, ) - // Assert - expect(res.status).toEqual(204) - expect( - delegationIndexService.indexCustomDelegations, - ).toHaveBeenCalled() - - const delegationAfter = await delegationModel.findByPk( - delegation.id, + it.each(testCase.expected)( + 'DELETE /v1/me/delegations/:id removes access to delegation', + async (domain) => { + // Arrange + const delegationScopeModel = app.get( + getModelToken(DelegationScope), + ) + const delegationModel = app.get( + getModelToken(Delegation), + ) + const delegation = delegations.find( + (delegation) => + delegation.domainName === domain.name && + delegation.toNationalId === testCase.user.nationalId, + ) + assert(delegation) + + // Act + const res = await server.delete( + `/v1/me/delegations/${delegation.id}`, + ) + + // Assert + expect(res.status).toEqual(204) + expect( + delegationIndexService.indexCustomDelegations, + ).toHaveBeenCalled() + + const delegationAfter = await delegationModel.findByPk( + delegation.id, + ) + expect(delegationAfter).toBeNull() + + const scopesAfter = await delegationScopeModel.findAll({ + where: { + delegationId: delegation.id, + scopeName: + delegation.delegationScopes?.map( + (scope) => scope.scopeName, + ) ?? [], + }, + }) + expect(scopesAfter).toHaveLength(0) + }, ) - expect(delegationAfter).toBeNull() - - const scopesAfter = await delegationScopeModel.findAll({ - where: { - delegationId: delegation.id, - scopeName: - delegation.delegationScopes?.map( - (scope) => scope.scopeName, - ) ?? [], + } + + if (testCase.expected.length === 0 && delegations.length > 0) { + it.each(delegations)( + 'GET /v1/me/delegation/:id returns no content response for $name', + async (delegation) => { + // Act + const res = await server.get( + `/v1/me/delegations/${delegation.id}`, + ) + + // Assert + expect(res.status).toEqual(204) + expect(res.body).toMatchObject({}) }, - }) - expect(scopesAfter).toHaveLength(0) - }, - ) - } - - if (testCase.expected.length === 0 && delegations.length > 0) { - it.each(delegations)( - 'GET /v1/me/delegation/:id returns no content response for $name', - async (delegation) => { - // Act - const res = await server.get(`/v1/me/delegations/${delegation.id}`) - - // Assert - expect(res.status).toEqual(204) - expect(res.body).toMatchObject({}) - }, - ) - } + ) + } + }, + ) }, ) }) diff --git a/apps/services/auth/ids-api/src/app/delegations/delegations-personal-representative.controller.spec.ts b/apps/services/auth/ids-api/src/app/delegations/delegations-personal-representative.controller.spec.ts index f0050b0d9e9e..5c07ba95334d 100644 --- a/apps/services/auth/ids-api/src/app/delegations/delegations-personal-representative.controller.spec.ts +++ b/apps/services/auth/ids-api/src/app/delegations/delegations-personal-representative.controller.spec.ts @@ -13,6 +13,7 @@ import { Domain, InactiveReason, MergedDelegationDTO, + NationalRegistryV3FeatureService, PersonalRepresentative, PersonalRepresentativeDelegationTypeModel, PersonalRepresentativeRight, @@ -22,6 +23,7 @@ import { UNKNOWN_NAME, } from '@island.is/auth-api-lib' import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3' import { createClient, createDomain, @@ -54,374 +56,190 @@ import { } from '../../../test/stubs/personalRepresentativeStubs' describe('Personal Representative DelegationsController', () => { - describe('Given a user is authenticated', () => { - let app: TestApp - let factory: FixtureFactory - let server: request.SuperTest - let apiScopeModel: typeof ApiScope - let clientModel: typeof Client - let prScopePermission: typeof PersonalRepresentativeScopePermission - let apiScopeDelegationTypeModel: typeof ApiScopeDelegationType - let prModel: typeof PersonalRepresentative - let prRightsModel: typeof PersonalRepresentativeRight - let prRightTypeModel: typeof PersonalRepresentativeRightType - let prTypeModel: typeof PersonalRepresentativeType - let prDelegationTypeModel: typeof PersonalRepresentativeDelegationTypeModel - let delegationTypeModel: typeof DelegationTypeModel - let nationalRegistryApi: NationalRegistryClientService - let delegationProviderModel: typeof DelegationProviderModel - let delegationIndexService: DelegationsIndexService - - const client = createClient({ - clientId: '@island.is/webapp', - }) - - const scopeValid1 = 'scope/valid1' - const scopeValid2 = 'scope/valid2' - const scopeValid1and2 = 'scope/valid1and2' - const scopeUnactiveType = 'scope/unactiveType' - const scopeOutdated = 'scope/outdated' - const disabledScope = 'disabledScope' - - client.allowedScopes = Object.values([ - scopeValid1, - scopeValid2, - scopeValid1and2, - scopeUnactiveType, - scopeOutdated, - disabledScope, - ]).map((s) => ({ - clientId: client.clientId, - scopeName: s, - })) - - const userNationalId = getFakeNationalId() - - const user = createCurrentUser({ - nationalId: userNationalId, - scope: [defaultScopes.testUserHasAccess.name], - client: client.clientId, - }) - - const domain = createDomain() - - beforeAll(async () => { - app = await setupWithAuth({ - user, - }) - server = request(app.getHttpServer()) - - prTypeModel = app.get( - getModelToken(PersonalRepresentativeType), - ) - - await prTypeModel.create(personalRepresentativeType) - - const domainModel = app.get(getModelToken(Domain)) - await domainModel.create(domain) - - apiScopeModel = app.get(getModelToken(ApiScope)) - prModel = app.get( - getModelToken(PersonalRepresentative), - ) - prRightsModel = app.get( - getModelToken(PersonalRepresentativeRight), - ) - prRightTypeModel = app.get( - getModelToken(PersonalRepresentativeRightType), - ) - prScopePermission = app.get( - getModelToken(PersonalRepresentativeScopePermission), - ) - apiScopeDelegationTypeModel = app.get( - getModelToken(ApiScopeDelegationType), - ) - prDelegationTypeModel = app.get< - typeof PersonalRepresentativeDelegationTypeModel - >(getModelToken(PersonalRepresentativeDelegationTypeModel)) - delegationTypeModel = app.get( - getModelToken(DelegationTypeModel), - ) - delegationProviderModel = app.get( - getModelToken(DelegationProviderModel), - ) - clientModel = app.get(getModelToken(Client)) - nationalRegistryApi = app.get(NationalRegistryClientService) - delegationIndexService = app.get(DelegationsIndexService) - factory = new FixtureFactory(app) - }) - - const createDelegationTypeAndProvider = async (rightCode: string[]) => { - const newDelegationProvider = await delegationProviderModel.create({ - id: AuthDelegationProvider.PersonalRepresentativeRegistry, - name: 'Talsmannagrunnur', - description: 'Talsmannagrunnur', - delegationTypes: [], - }) - - await delegationTypeModel.bulkCreate( - rightCode.map((code) => { - return { - id: getPersonalRepresentativeDelegationType(code), - providerId: newDelegationProvider.id, - name: getPersonalRepresentativeDelegationType(code), - description: `Personal representative delegation type for right type ${code}`, - } - }), - ) - } - - afterAll(async () => { - await app.cleanUp() - }) - - describe('and given we have 3 valid, 1 not yet active and 1 outdate right types', () => { - type rightsTypeStatus = 'valid' | 'unactivated' | 'outdated' - type rightsType = [code: string, status: rightsTypeStatus] - const rightsTypes: rightsType[] = [ - ['valid1', 'valid'], - ['valid2', 'valid'], - ['unactivated', 'unactivated'], - ['outdated', 'outdated'], - ] - - beforeAll(async () => { - await prRightTypeModel.bulkCreate( - rightsTypes.map(([code, status]) => { - switch (status) { - case 'valid': - return getPersonalRepresentativeRightType(code) - case 'unactivated': - return getPersonalRepresentativeRightType( - code, - faker.date.soon(7), - ) - case 'outdated': - return getPersonalRepresentativeRightType( - code, - faker.date.recent(5), - faker.date.recent(), - ) - } - }), - ) - await createDelegationTypeAndProvider(rightsTypes.map(([code]) => code)) - - client.supportedDelegationTypes = delegationTypes - await factory.createClient(client) - }) - - afterAll(async () => { - await prRightTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, + describe.each([false, true])( + 'national registry v3 featureflag: %s', + (featureFlag) => { + describe('Given a user is authenticated', () => { + let app: TestApp + let factory: FixtureFactory + let server: request.SuperTest + let apiScopeModel: typeof ApiScope + let clientModel: typeof Client + let prScopePermission: typeof PersonalRepresentativeScopePermission + let apiScopeDelegationTypeModel: typeof ApiScopeDelegationType + let prModel: typeof PersonalRepresentative + let prRightsModel: typeof PersonalRepresentativeRight + let prRightTypeModel: typeof PersonalRepresentativeRightType + let prTypeModel: typeof PersonalRepresentativeType + let prDelegationTypeModel: typeof PersonalRepresentativeDelegationTypeModel + let delegationTypeModel: typeof DelegationTypeModel + let nationalRegistryApi: NationalRegistryClientService + let nationalRegistryV3Api: NationalRegistryV3ClientService + let delegationProviderModel: typeof DelegationProviderModel + let delegationIndexService: DelegationsIndexService + + const client = createClient({ + clientId: '@island.is/webapp', }) - await delegationTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await delegationProviderModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, + + const scopeValid1 = 'scope/valid1' + const scopeValid2 = 'scope/valid2' + const scopeValid1and2 = 'scope/valid1and2' + const scopeUnactiveType = 'scope/unactiveType' + const scopeOutdated = 'scope/outdated' + const disabledScope = 'disabledScope' + + client.allowedScopes = Object.values([ + scopeValid1, + scopeValid2, + scopeValid1and2, + scopeUnactiveType, + scopeOutdated, + disabledScope, + ]).map((s) => ({ + clientId: client.clientId, + scopeName: s, + })) + + const userNationalId = getFakeNationalId() + + const user = createCurrentUser({ + nationalId: userNationalId, + scope: [defaultScopes.testUserHasAccess.name], + client: client.clientId, }) - }) - describe.each([ - [1, 0, 0, 2, 1], - [2, 0, 0, 1, 0], - [0, 0, 0, 0, 0], - [0, 1, 0, 0, 1], - [0, 0, 1, 0, 0], - [0, 1, 1, 0, 2], - [1, 1, 1, 0, 0], - ])( - 'and given user has %d active representees with valid rights, %d active representees with outdated rights and %d active representees with unactivated', - ( - valid: number, - outdated: number, - unactivated: number, - deceased: number, - nationalRegistryErrors: number, - ) => { - let nationalRegistryApiSpy: jest.SpyInstance - const validRepresentedPersons: NameIdTuple[] = [] - const outdatedRepresentedPersons: NameIdTuple[] = [] - const unactivatedRepresentedPersons: NameIdTuple[] = [] - const errorNationalIdsRepresentedPersons: NameIdTuple[] = [] - const deceasedNationalIds = times(deceased, getFakeNationalId) - const errorNationalIds = times( - nationalRegistryErrors, - getFakeNationalId, + const domain = createDomain() + + beforeAll(async () => { + app = await setupWithAuth({ + user, + }) + server = request(app.getHttpServer()) + + prTypeModel = app.get( + getModelToken(PersonalRepresentativeType), ) - beforeAll(async () => { - for (let i = 0; i < valid; i++) { - const representedPerson: NameIdTuple = [ - getFakeName(), - getFakeNationalId(), - ] - const relationship = getPersonalRepresentativeRelationship( - userNationalId, - representedPerson[1], - ) - validRepresentedPersons.push(representedPerson) - await prModel.create(relationship) - await prRightsModel.create( - getPersonalRepresentativeRights('valid1', relationship.id), - ) - await prDelegationTypeModel.create({ - personalRepresentativeId: relationship.id, - delegationTypeId: - getPersonalRepresentativeDelegationType('valid1'), - }) - } - - for (let i = 0; i < outdated; i++) { - const representedPerson: NameIdTuple = [ - getFakeName(), - getFakeNationalId(), - ] - const relationship = getPersonalRepresentativeRelationship( - userNationalId, - representedPerson[1], - ) - outdatedRepresentedPersons.push(representedPerson) - await prModel.create(relationship) - await prRightsModel.create( - getPersonalRepresentativeRights('outdated', relationship.id), - ) - await prDelegationTypeModel.create({ - personalRepresentativeId: relationship.id, - delegationTypeId: - getPersonalRepresentativeDelegationType('outdated'), - }) - } - - for (let i = 0; i < unactivated; i++) { - const representedPerson: NameIdTuple = [ - getFakeName(), - getFakeNationalId(), - ] - const relationship = getPersonalRepresentativeRelationship( - userNationalId, - representedPerson[1], - ) - unactivatedRepresentedPersons.push(representedPerson) - await prModel.create(relationship) - await prRightsModel.create( - getPersonalRepresentativeRights('unactivated', relationship.id), - ) - await prDelegationTypeModel.create({ - personalRepresentativeId: relationship.id, - delegationTypeId: - getPersonalRepresentativeDelegationType('unactivated'), - }) - } - - for (let i = 0; i < deceased; i++) { - const representedPerson: NameIdTuple = [ - getFakeName(), - deceasedNationalIds[i], - ] - const relationship = getPersonalRepresentativeRelationship( - userNationalId, - representedPerson[1], - ) + await prTypeModel.create(personalRepresentativeType) - await prModel.create(relationship) - await prRightsModel.create( - getPersonalRepresentativeRights('valid1', relationship.id), - ) - await prDelegationTypeModel.create({ - personalRepresentativeId: relationship.id, - delegationTypeId: - getPersonalRepresentativeDelegationType('valid1'), - }) - } - - for (let i = 0; i < nationalRegistryErrors; i++) { - const representedPerson: NameIdTuple = [ - UNKNOWN_NAME, - errorNationalIds[i], - ] - const relationship = getPersonalRepresentativeRelationship( - userNationalId, - representedPerson[1], - ) + const domainModel = app.get(getModelToken(Domain)) + await domainModel.create(domain) - errorNationalIdsRepresentedPersons.push(representedPerson) - // Create Personal Representative model which will have nationalIdRepresentedPerson throw an error - // when national registry api getIndividual is called - await prModel.create(relationship) - await prRightsModel.create( - getPersonalRepresentativeRights('valid1', relationship.id), - ) - await prDelegationTypeModel.create({ - personalRepresentativeId: relationship.id, - delegationTypeId: - getPersonalRepresentativeDelegationType('valid1'), - }) - } - - const nationalRegistryUsers = [ - ...validRepresentedPersons.map(([name, nationalId]) => - createNationalRegistryUser({ name, nationalId }), - ), - ...outdatedRepresentedPersons.map(([name, nationalId]) => - createNationalRegistryUser({ name, nationalId }), - ), - ...unactivatedRepresentedPersons.map(([name, nationalId]) => - createNationalRegistryUser({ name, nationalId }), - ), - ] + apiScopeModel = app.get(getModelToken(ApiScope)) + prModel = app.get( + getModelToken(PersonalRepresentative), + ) + prRightsModel = app.get( + getModelToken(PersonalRepresentativeRight), + ) + prRightTypeModel = app.get( + getModelToken(PersonalRepresentativeRightType), + ) + prScopePermission = app.get< + typeof PersonalRepresentativeScopePermission + >(getModelToken(PersonalRepresentativeScopePermission)) + apiScopeDelegationTypeModel = app.get( + getModelToken(ApiScopeDelegationType), + ) + prDelegationTypeModel = app.get< + typeof PersonalRepresentativeDelegationTypeModel + >(getModelToken(PersonalRepresentativeDelegationTypeModel)) + delegationTypeModel = app.get( + getModelToken(DelegationTypeModel), + ) + delegationProviderModel = app.get( + getModelToken(DelegationProviderModel), + ) + clientModel = app.get(getModelToken(Client)) + nationalRegistryApi = app.get(NationalRegistryClientService) + nationalRegistryV3Api = app.get(NationalRegistryV3ClientService) + delegationIndexService = app.get(DelegationsIndexService) + const nationalRegistryV3FeatureService = app.get( + NationalRegistryV3FeatureService, + ) + jest + .spyOn(nationalRegistryV3FeatureService, 'getValue') + .mockImplementation(async () => featureFlag) + factory = new FixtureFactory(app) + }) - nationalRegistryApiSpy = jest - .spyOn(nationalRegistryApi, 'getIndividual') - .mockImplementation(async (id) => { - if (deceasedNationalIds.includes(id)) { - return null - } + const createDelegationTypeAndProvider = async (rightCode: string[]) => { + const newDelegationProvider = await delegationProviderModel.create({ + id: AuthDelegationProvider.PersonalRepresentativeRegistry, + name: 'Talsmannagrunnur', + description: 'Talsmannagrunnur', + delegationTypes: [], + }) - if ( - errorNationalIds.find( - (errorNationalId) => id === errorNationalId, - ) - ) { - throw new Error('National registry error') - } + await delegationTypeModel.bulkCreate( + rightCode.map((code) => { + return { + id: getPersonalRepresentativeDelegationType(code), + providerId: newDelegationProvider.id, + name: getPersonalRepresentativeDelegationType(code), + description: `Personal representative delegation type for right type ${code}`, + } + }), + ) + } - const user = nationalRegistryUsers.find( - (u) => - u?.nationalId === id && - // Make sure we don't return a user that has been marked as deceased - !deceasedNationalIds.includes(u?.nationalId), - ) + afterAll(async () => { + await app.cleanUp() + }) - return user ?? null - }) + describe('and given we have 3 valid, 1 not yet active and 1 outdate right types', () => { + type rightsTypeStatus = 'valid' | 'unactivated' | 'outdated' + type rightsType = [code: string, status: rightsTypeStatus] + const rightsTypes: rightsType[] = [ + ['valid1', 'valid'], + ['valid2', 'valid'], + ['unactivated', 'unactivated'], + ['outdated', 'outdated'], + ] + + beforeAll(async () => { + await prRightTypeModel.bulkCreate( + rightsTypes.map(([code, status]) => { + switch (status) { + case 'valid': + return getPersonalRepresentativeRightType(code) + case 'unactivated': + return getPersonalRepresentativeRightType( + code, + faker.date.soon(7), + ) + case 'outdated': + return getPersonalRepresentativeRightType( + code, + faker.date.recent(5), + faker.date.recent(), + ) + } + }), + ) + await createDelegationTypeAndProvider( + rightsTypes.map(([code]) => code), + ) + + client.supportedDelegationTypes = delegationTypes + await factory.createClient(client) }) afterAll(async () => { - jest.clearAllMocks() - await prRightsModel.destroy({ + await prRightTypeModel.destroy({ where: {}, cascade: true, truncate: true, force: true, }) - await prModel.destroy({ + await delegationTypeModel.destroy({ where: {}, cascade: true, truncate: true, force: true, }) - await prDelegationTypeModel.destroy({ + await delegationProviderModel.destroy({ where: {}, cascade: true, truncate: true, @@ -429,215 +247,409 @@ describe('Personal Representative DelegationsController', () => { }) }) - describe('when user calls GET /v2/delegations', () => { - const path = '/v2/delegations' - let response: request.Response - let body: MergedDelegationDTO[] + describe.each([ + [1, 0, 0, 2, 1], + [2, 0, 0, 1, 0], + [0, 0, 0, 0, 0], + [0, 1, 0, 0, 1], + [0, 0, 1, 0, 0], + [0, 1, 1, 0, 2], + [1, 1, 1, 0, 0], + ])( + 'and given user has %d active representees with valid rights, %d active representees with outdated rights and %d active representees with unactivated', + ( + valid: number, + outdated: number, + unactivated: number, + deceased: number, + nationalRegistryErrors: number, + ) => { + let nationalRegistryApiSpy: jest.SpyInstance + let nationalRegistryV3ApiSpy: jest.SpyInstance + const validRepresentedPersons: NameIdTuple[] = [] + const outdatedRepresentedPersons: NameIdTuple[] = [] + const unactivatedRepresentedPersons: NameIdTuple[] = [] + const errorNationalIdsRepresentedPersons: NameIdTuple[] = [] + const deceasedNationalIds = times(deceased, getFakeNationalId) + const errorNationalIds = times( + nationalRegistryErrors, + getFakeNationalId, + ) - beforeAll(async () => { - response = await server.get(path) - body = response.body - }) + beforeAll(async () => { + for (let i = 0; i < valid; i++) { + const representedPerson: NameIdTuple = [ + getFakeName(), + getFakeNationalId(), + ] + const relationship = getPersonalRepresentativeRelationship( + userNationalId, + representedPerson[1], + ) + validRepresentedPersons.push(representedPerson) + await prModel.create(relationship) + await prRightsModel.create( + getPersonalRepresentativeRights('valid1', relationship.id), + ) + await prDelegationTypeModel.create({ + personalRepresentativeId: relationship.id, + delegationTypeId: + getPersonalRepresentativeDelegationType('valid1'), + }) + } - it('should have a an OK return status', () => { - expect(response.status).toEqual(200) - }) + for (let i = 0; i < outdated; i++) { + const representedPerson: NameIdTuple = [ + getFakeName(), + getFakeNationalId(), + ] + const relationship = getPersonalRepresentativeRelationship( + userNationalId, + representedPerson[1], + ) + outdatedRepresentedPersons.push(representedPerson) + await prModel.create(relationship) + await prRightsModel.create( + getPersonalRepresentativeRights( + 'outdated', + relationship.id, + ), + ) + await prDelegationTypeModel.create({ + personalRepresentativeId: relationship.id, + delegationTypeId: + getPersonalRepresentativeDelegationType('outdated'), + }) + } - it(`should return ${valid} ${ - valid === 1 ? 'item' : 'items' - } `, () => { - expect(body).toHaveLength(valid + nationalRegistryErrors) - }) + for (let i = 0; i < unactivated; i++) { + const representedPerson: NameIdTuple = [ + getFakeName(), + getFakeNationalId(), + ] + const relationship = getPersonalRepresentativeRelationship( + userNationalId, + representedPerson[1], + ) + unactivatedRepresentedPersons.push(representedPerson) + await prModel.create(relationship) + await prRightsModel.create( + getPersonalRepresentativeRights( + 'unactivated', + relationship.id, + ), + ) + await prDelegationTypeModel.create({ + personalRepresentativeId: relationship.id, + delegationTypeId: + getPersonalRepresentativeDelegationType('unactivated'), + }) + } - it('should have the nationalId of the user as the representer', () => { - expect( - body.every((d) => d.toNationalId === userNationalId), - ).toBeTruthy() - }) + for (let i = 0; i < deceased; i++) { + const representedPerson: NameIdTuple = [ + getFakeName(), + deceasedNationalIds[i], + ] + const relationship = getPersonalRepresentativeRelationship( + userNationalId, + representedPerson[1], + ) - it('should only have the nationalId of the valid representees', () => { - expect(body.map((d) => d.fromNationalId).sort()).toEqual( - [ - ...validRepresentedPersons.map(([_, id]) => id), - ...errorNationalIdsRepresentedPersons.map(([_, id]) => id), - ].sort(), - ) - }) + await prModel.create(relationship) + await prRightsModel.create( + getPersonalRepresentativeRights('valid1', relationship.id), + ) + await prDelegationTypeModel.create({ + personalRepresentativeId: relationship.id, + delegationTypeId: + getPersonalRepresentativeDelegationType('valid1'), + }) + } - it(`should only have ${ - valid + nationalRegistryErrors === 1 ? 'name' : 'names' - } of the valid represented ${ - valid + nationalRegistryErrors === 1 ? 'person' : 'persons' - }`, () => { - expect(body.map((d) => d.fromName).sort()).toEqual([ - ...validRepresentedPersons.map(([name, _]) => name).sort(), - ...errorNationalIdsRepresentedPersons.map(([name]) => name), - ]) - }) + for (let i = 0; i < nationalRegistryErrors; i++) { + const representedPerson: NameIdTuple = [ + UNKNOWN_NAME, + errorNationalIds[i], + ] + const relationship = getPersonalRepresentativeRelationship( + userNationalId, + representedPerson[1], + ) - it(`should have fetched the ${ - valid + deceased + nationalRegistryErrors === 1 ? 'name' : 'names' - } of the valid represented ${ - valid + deceased + nationalRegistryErrors === 1 - ? 'person' - : 'persons' - } from nationalRegistryApi`, () => { - expect(nationalRegistryApiSpy).toHaveBeenCalledTimes( - valid + deceased + nationalRegistryErrors, - ) - }) + errorNationalIdsRepresentedPersons.push(representedPerson) + // Create Personal Representative model which will have nationalIdRepresentedPerson throw an error + // when national registry api getIndividual is called + await prModel.create(relationship) + await prRightsModel.create( + getPersonalRepresentativeRights('valid1', relationship.id), + ) + await prDelegationTypeModel.create({ + personalRepresentativeId: relationship.id, + delegationTypeId: + getPersonalRepresentativeDelegationType('valid1'), + }) + } - it('should have the delegation type claims of PersonalRepresentative', () => { - expect( - body.every( - (d) => - d.types[0] === AuthDelegationType.PersonalRepresentative, - ), - ).toBeTruthy() - }) + const nationalRegistryUsers = [ + ...validRepresentedPersons.map(([name, nationalId]) => + createNationalRegistryUser({ name, nationalId }), + ), + ...outdatedRepresentedPersons.map(([name, nationalId]) => + createNationalRegistryUser({ name, nationalId }), + ), + ...unactivatedRepresentedPersons.map(([name, nationalId]) => + createNationalRegistryUser({ name, nationalId }), + ), + ] + + nationalRegistryApiSpy = jest + .spyOn(nationalRegistryApi, 'getIndividual') + .mockImplementation(async (id) => { + if (deceasedNationalIds.includes(id)) { + return null + } + + if ( + errorNationalIds.find( + (errorNationalId) => id === errorNationalId, + ) + ) { + throw new Error('National registry error') + } + + const user = nationalRegistryUsers.find( + (u) => + u?.nationalId === id && + // Make sure we don't return a user that has been marked as deceased + !deceasedNationalIds.includes(u?.nationalId), + ) + + return user ?? null + }) + + nationalRegistryV3ApiSpy = jest + .spyOn(nationalRegistryV3Api, 'getAllDataIndividual') + .mockImplementation(async (id) => { + if ( + errorNationalIds.find( + (errorNationalId) => id === errorNationalId, + ) + ) { + throw new Error('National registry error') + } + + if (deceasedNationalIds.includes(id)) { + return { + kennitala: id, + afdrif: 'LÉST', + } + } + + const user = nationalRegistryUsers.find( + (u) => u?.nationalId === id, + ) + + return user + ? { + kennitala: id, + nafn: user?.name, + afdrif: null, + } + : null + }) + }) - it('should have made prModels inactive for deceased persons', async () => { - // Arrange - const expectedModels = await prModel.findAll({ - where: { - nationalIdRepresentedPerson: deceasedNationalIds, - inactive: true, - inactiveReason: InactiveReason.DECEASED_PARTY, - }, + afterAll(async () => { + jest.clearAllMocks() + await prRightsModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) }) - // Assert - expect(expectedModels.length).toEqual(deceased) + describe('when user calls GET /v2/delegations', () => { + const path = '/v2/delegations' + let response: request.Response + let body: MergedDelegationDTO[] - expectedModels.forEach((model) => { - expect(model.inactive).toEqual(true) - expect(model.inactiveReason).toEqual( - InactiveReason.DECEASED_PARTY, - ) - }) - }) + beforeAll(async () => { + response = await server.get(path) + body = response.body + }) - it('should return delegation if national registry api getIndividual throws an error', async () => { - // Arrange - const expectedModels = await prModel.findAll({ - where: { - nationalIdRepresentedPerson: errorNationalIds, - }, - }) + it('should have a an OK return status', () => { + expect(response.status).toEqual(200) + }) - // Assert - expect(expectedModels.length).toEqual(errorNationalIds.length) - }) + it(`should return ${valid} ${ + valid === 1 ? 'item' : 'items' + } `, () => { + expect(body).toHaveLength(valid + nationalRegistryErrors) + }) - it('should index delegations', () => { - expect(delegationIndexService.indexDelegations).toHaveBeenCalled() - }) - }) - }, - ) - - describe('and given we have a combination of scopes for personal representative', () => { - type scopesType = [name: string, enabled: boolean, rightTypes: string[]] - const scopes: scopesType[] = [ - [scopeValid1, true, ['valid1']], - [scopeValid2, true, ['valid2']], - [scopeValid1and2, true, ['valid1', 'valid2']], - [scopeUnactiveType, true, ['unactivated']], - [scopeOutdated, true, ['outdated']], - [disabledScope, false, ['valid1']], - ] + it('should have the nationalId of the user as the representer', () => { + expect( + body.every((d) => d.toNationalId === userNationalId), + ).toBeTruthy() + }) - beforeAll(async () => { - const apiScopes = scopes.flatMap(([name, enabled, types]) => ({ - name: name, - enabled, - domainName: domain.name, - supportedDelegationTypes: types.map((rt) => - getPersonalRepresentativeDelegationType(rt), - ), - })) - - await Promise.all( - apiScopes.map((scope) => factory.createApiScope(scope)), - ) + it('should only have the nationalId of the valid representees', () => { + expect(body.map((d) => d.fromNationalId).sort()).toEqual( + [ + ...validRepresentedPersons.map(([_, id]) => id), + ...errorNationalIdsRepresentedPersons.map( + ([_, id]) => id, + ), + ].sort(), + ) + }) - await prScopePermission.bulkCreate( - scopes.flatMap(([name, _, types]) => - types.map((rt) => getScopePermission(rt, name)), - ), - ) - }) + it(`should only have ${ + valid + nationalRegistryErrors === 1 ? 'name' : 'names' + } of the valid represented ${ + valid + nationalRegistryErrors === 1 ? 'person' : 'persons' + }`, () => { + expect(body.map((d) => d.fromName).sort()).toEqual([ + ...validRepresentedPersons.map(([name, _]) => name).sort(), + ...errorNationalIdsRepresentedPersons.map(([name]) => name), + ]) + }) - afterAll(async () => { - await prScopePermission.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await apiScopeDelegationTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await apiScopeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - }) + it(`should have fetched the ${ + valid + deceased + nationalRegistryErrors === 1 + ? 'name' + : 'names' + } of the valid represented ${ + valid + deceased + nationalRegistryErrors === 1 + ? 'person' + : 'persons' + } from nationalRegistryApi`, () => { + featureFlag + ? expect(nationalRegistryV3ApiSpy).toHaveBeenCalledTimes( + valid + deceased + nationalRegistryErrors, + ) + : expect(nationalRegistryApiSpy).toHaveBeenCalledTimes( + valid + deceased + nationalRegistryErrors, + ) + }) + + it('should have the delegation type claims of PersonalRepresentative', () => { + expect( + body.every( + (d) => + d.types[0] === + AuthDelegationType.PersonalRepresentative, + ), + ).toBeTruthy() + }) + + it('should have made prModels inactive for deceased persons', async () => { + // Arrange + const expectedModels = await prModel.findAll({ + where: { + nationalIdRepresentedPerson: deceasedNationalIds, + inactive: true, + inactiveReason: InactiveReason.DECEASED_PARTY, + }, + }) + + // Assert + expect(expectedModels.length).toEqual(deceased) + + expectedModels.forEach((model) => { + expect(model.inactive).toEqual(true) + expect(model.inactiveReason).toEqual( + InactiveReason.DECEASED_PARTY, + ) + }) + }) - describe.each([ - [['valid1'], [scopeValid1, scopeValid1and2]], - [['valid2'], [scopeValid2, scopeValid1and2]], - [ - ['valid1', 'valid2'], - [scopeValid1, scopeValid2, scopeValid1and2], - ], - [[], []], - // [['unactivated'], []], - // [['outdated'], []], - ])( - 'and given user is representing persons with rights %p', - (rights, expected) => { - const representeeNationalId = getFakeNationalId() + it('should return delegation if national registry api getIndividual throws an error', async () => { + // Arrange + const expectedModels = await prModel.findAll({ + where: { + nationalIdRepresentedPerson: errorNationalIds, + }, + }) + + // Assert + expect(expectedModels.length).toEqual(errorNationalIds.length) + }) + + it('should index delegations', () => { + expect( + delegationIndexService.indexDelegations, + ).toHaveBeenCalled() + }) + }) + }, + ) + + describe('and given we have a combination of scopes for personal representative', () => { + type scopesType = [ + name: string, + enabled: boolean, + rightTypes: string[], + ] + const scopes: scopesType[] = [ + [scopeValid1, true, ['valid1']], + [scopeValid2, true, ['valid2']], + [scopeValid1and2, true, ['valid1', 'valid2']], + [scopeUnactiveType, true, ['unactivated']], + [scopeOutdated, true, ['outdated']], + [disabledScope, false, ['valid1']], + ] beforeAll(async () => { - const relationship = getPersonalRepresentativeRelationship( - userNationalId, - representeeNationalId, + const apiScopes = scopes.flatMap(([name, enabled, types]) => ({ + name: name, + enabled, + domainName: domain.name, + supportedDelegationTypes: types.map((rt) => + getPersonalRepresentativeDelegationType(rt), + ), + })) + + await Promise.all( + apiScopes.map((scope) => factory.createApiScope(scope)), ) - await prModel.create(relationship) - await prRightsModel.bulkCreate( - rights.map((r) => - getPersonalRepresentativeRights(r, relationship.id), + await prScopePermission.bulkCreate( + scopes.flatMap(([name, _, types]) => + types.map((rt) => getScopePermission(rt, name)), ), ) - await prDelegationTypeModel.bulkCreate( - rights.map((r) => ({ - personalRepresentativeId: relationship.id, - delegationTypeId: getPersonalRepresentativeDelegationType(r), - })), - ) }) afterAll(async () => { - await prRightsModel.destroy({ + await prScopePermission.destroy({ where: {}, cascade: true, truncate: true, force: true, }) - await prModel.destroy({ + await apiScopeDelegationTypeModel.destroy({ where: {}, cascade: true, truncate: true, force: true, }) - await prDelegationTypeModel.destroy({ + await apiScopeModel.destroy({ where: {}, cascade: true, truncate: true, @@ -645,34 +657,95 @@ describe('Personal Representative DelegationsController', () => { }) }) - describe('when user calls GET /delegations/scopes', () => { - const path = '/delegations/scopes' - let response: request.Response - let body: string[] + describe.each([ + [['valid1'], [scopeValid1, scopeValid1and2]], + [['valid2'], [scopeValid2, scopeValid1and2]], + [ + ['valid1', 'valid2'], + [scopeValid1, scopeValid2, scopeValid1and2], + ], + [[], []], + // [['unactivated'], []], + // [['outdated'], []], + ])( + 'and given user is representing persons with rights %p', + (rights, expected) => { + const representeeNationalId = getFakeNationalId() + + beforeAll(async () => { + const relationship = getPersonalRepresentativeRelationship( + userNationalId, + representeeNationalId, + ) - beforeAll(async () => { - response = await server.get(`${path}`).query({ - fromNationalId: representeeNationalId, - delegationType: rights.map((r) => - getPersonalRepresentativeDelegationType(r), - ), + await prModel.create(relationship) + await prRightsModel.bulkCreate( + rights.map((r) => + getPersonalRepresentativeRights(r, relationship.id), + ), + ) + await prDelegationTypeModel.bulkCreate( + rights.map((r) => ({ + personalRepresentativeId: relationship.id, + delegationTypeId: + getPersonalRepresentativeDelegationType(r), + })), + ) }) - body = response.body - }) - it('should have a an OK return status', () => { - expect(response.status).toEqual(200) - }) + afterAll(async () => { + await prRightsModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + }) - it(`should return ${ - expected.length === 0 ? 'no scopes' : JSON.stringify(expected) - }`, () => { - expect(body.sort()).toEqual(expected.sort()) - }) - }) - }, - ) + describe('when user calls GET /delegations/scopes', () => { + const path = '/delegations/scopes' + let response: request.Response + let body: string[] + + beforeAll(async () => { + response = await server.get(`${path}`).query({ + fromNationalId: representeeNationalId, + delegationType: rights.map((r) => + getPersonalRepresentativeDelegationType(r), + ), + }) + body = response.body + }) + + it('should have a an OK return status', () => { + expect(response.status).toEqual(200) + }) + + it(`should return ${ + expected.length === 0 + ? 'no scopes' + : JSON.stringify(expected) + }`, () => { + expect(body.sort()).toEqual(expected.sort()) + }) + }) + }, + ) + }) + }) }) - }) - }) + }, + ) }) diff --git a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts index 526bf4262a4a..eb6c5a885989 100644 --- a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts +++ b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts @@ -4,9 +4,13 @@ import faker from 'faker' import { Sequelize } from 'sequelize-typescript' import request from 'supertest' -import { MergedDelegationDTO } from '@island.is/auth-api-lib' +import { + MergedDelegationDTO, + NationalRegistryV3FeatureService, +} from '@island.is/auth-api-lib' import { RskRelationshipsClient } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3' import { FixtureFactory } from '@island.is/services/auth/testing' import { AuthDelegationProvider, @@ -23,166 +27,190 @@ import { testCases } from './delegations-filters-test-cases' import { user } from './delegations-filters-types' describe('DelegationsController', () => { - let sequelize: Sequelize - let app: TestApp - let server: request.SuperTest - let factory: FixtureFactory - let nationalRegistryApi: NationalRegistryClientService - let rskApi: RskRelationshipsClient - beforeAll(async () => { - app = await setupWithAuth({ - user: user, - }) - sequelize = await app.resolve(getConnectionToken() as Type) - - server = request(app.getHttpServer()) - - nationalRegistryApi = app.get(NationalRegistryClientService) - jest - .spyOn(nationalRegistryApi, 'getIndividual') - .mockImplementation(async (nationalId: string) => - createNationalRegistryUser({ - nationalId, - name: faker.name.findName(), - }), - ) - rskApi = app.get(RskRelationshipsClient) - - factory = new FixtureFactory(app) - }) - - afterAll(async () => { - await app.cleanUp() - }) - - describe.each(Object.keys(testCases))( - 'Delegation filtering with test case: %s', - (caseName) => { - const testCase = testCases[caseName] - testCase.user = user - const path = '/v2/delegations' - + describe.each([false, true])( + 'national registry v3 featureflag: %s', + (featureFlag) => { + let sequelize: Sequelize + let app: TestApp + let server: request.SuperTest + let factory: FixtureFactory + let nationalRegistryApi: NationalRegistryClientService + let nationalRegistryV3Api: NationalRegistryV3ClientService + let rskApi: RskRelationshipsClient beforeAll(async () => { - await truncate(sequelize) - - await Promise.all( - testCase.domains.map((domain) => factory.createDomain(domain)), - ) + app = await setupWithAuth({ + user: user, + }) + sequelize = await app.resolve(getConnectionToken() as Type) - await factory.createClient(testCase.client) - - await Promise.all( - testCase.clientAllowedScopes.map((scope) => - factory.createClientAllowedScope(scope), - ), - ) + server = request(app.getHttpServer()) - await Promise.all( - testCase.apiScopes.map((scope) => factory.createApiScope(scope)), - ) - - await Promise.all( - testCase.apiScopeUserAccess.map((access) => - factory.createApiScopeUserAccess(access), - ), - ) - - await Promise.all( - testCase.customDelegations.map((delegation) => - factory.createCustomDelegation(delegation), - ), - ) - - await Promise.all( - testCase.fromLegalRepresentative.map((nationalId) => - factory.createDelegationIndexRecord({ - fromNationalId: nationalId, - toNationalId: testCase.user.nationalId, - type: AuthDelegationType.LegalRepresentative, - provider: AuthDelegationProvider.DistrictCommissionersRegistry, + nationalRegistryApi = app.get(NationalRegistryClientService) + jest + .spyOn(nationalRegistryApi, 'getIndividual') + .mockImplementation(async (nationalId: string) => + createNationalRegistryUser({ + nationalId, + name: faker.name.findName(), }), - ), + ) + nationalRegistryV3Api = app.get(NationalRegistryV3ClientService) + jest + .spyOn(nationalRegistryV3Api, 'getAllDataIndividual') + .mockImplementation(async (nationalId: string) => { + const user = createNationalRegistryUser({ + nationalId: nationalId, + }) + + return { kennitala: user.nationalId, nafn: user.name } + }) + rskApi = app.get(RskRelationshipsClient) + + const nationalRegistryV3FeatureService = app.get( + NationalRegistryV3FeatureService, ) - jest - .spyOn(nationalRegistryApi, 'getCustodyChildren') - .mockImplementation(async () => testCase.fromChildren) + .spyOn(nationalRegistryV3FeatureService, 'getValue') + .mockImplementation(async () => featureFlag) - jest - .spyOn(rskApi, 'getIndividualRelationships') - .mockImplementation(async () => testCase.procuration) + factory = new FixtureFactory(app) }) - let res: request.Response - - it(`GET ${path} returns correct filtered delegations`, async () => { - res = await server.get(path) - - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(testCase.expectedFrom.length) - expect( - res.body.map((d: MergedDelegationDTO) => d.fromNationalId).sort(), - ).toEqual(testCase.expectedFrom.sort()) - if (testCase.expectedTypes) { - expect(res.body[0].types.sort()).toEqual( - testCase.expectedTypes.sort(), - ) - } + afterAll(async () => { + await app.cleanUp() }) - }, - ) - - describe('verify', () => { - const testCase = testCases['legalRepresentative1'] - testCase.user = user - const path = '/v1/delegations/verify' - beforeAll(async () => { - await truncate(sequelize) - - await Promise.all( - testCase.domains.map((domain) => factory.createDomain(domain)), + describe.each(Object.keys(testCases))( + 'Delegation filtering with test case: %s', + (caseName) => { + const testCase = testCases[caseName] + testCase.user = user + const path = '/v2/delegations' + + beforeAll(async () => { + await truncate(sequelize) + + await Promise.all( + testCase.domains.map((domain) => factory.createDomain(domain)), + ) + + await factory.createClient(testCase.client) + + await Promise.all( + testCase.clientAllowedScopes.map((scope) => + factory.createClientAllowedScope(scope), + ), + ) + + await Promise.all( + testCase.apiScopes.map((scope) => factory.createApiScope(scope)), + ) + + await Promise.all( + testCase.apiScopeUserAccess.map((access) => + factory.createApiScopeUserAccess(access), + ), + ) + + await Promise.all( + testCase.customDelegations.map((delegation) => + factory.createCustomDelegation(delegation), + ), + ) + + await Promise.all( + testCase.fromLegalRepresentative.map((nationalId) => + factory.createDelegationIndexRecord({ + fromNationalId: nationalId, + toNationalId: testCase.user.nationalId, + type: AuthDelegationType.LegalRepresentative, + provider: + AuthDelegationProvider.DistrictCommissionersRegistry, + }), + ), + ) + + jest + .spyOn(nationalRegistryApi, 'getCustodyChildren') + .mockImplementation(async () => testCase.fromChildren) + + jest + .spyOn(rskApi, 'getIndividualRelationships') + .mockImplementation(async () => testCase.procuration) + }) + + let res: request.Response + + it(`GET ${path} returns correct filtered delegations`, async () => { + res = await server.get(path) + + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(testCase.expectedFrom.length) + expect( + res.body.map((d: MergedDelegationDTO) => d.fromNationalId).sort(), + ).toEqual(testCase.expectedFrom.sort()) + if (testCase.expectedTypes) { + expect(res.body[0].types.sort()).toEqual( + testCase.expectedTypes.sort(), + ) + } + }) + }, ) - await factory.createClient(testCase.client) + describe('verify', () => { + const testCase = testCases['legalRepresentative1'] + testCase.user = user + const path = '/v1/delegations/verify' - await Promise.all( - testCase.clientAllowedScopes.map((scope) => - factory.createClientAllowedScope(scope), - ), - ) + beforeAll(async () => { + await truncate(sequelize) - await Promise.all( - testCase.apiScopes.map((scope) => factory.createApiScope(scope)), - ) + await Promise.all( + testCase.domains.map((domain) => factory.createDomain(domain)), + ) - await factory.createDelegationIndexRecord({ - fromNationalId: nonExistingLegalRepresentativeNationalId, - toNationalId: testCase.user.nationalId, - type: AuthDelegationType.LegalRepresentative, - provider: AuthDelegationProvider.DistrictCommissionersRegistry, - }) - }) + await factory.createClient(testCase.client) - let res: request.Response - it(`POST ${path} returns verified response`, async () => { - res = await server.post(path).send({ - fromNationalId: testCase.fromLegalRepresentative[0], - delegationTypes: [AuthDelegationType.LegalRepresentative], - }) + await Promise.all( + testCase.clientAllowedScopes.map((scope) => + factory.createClientAllowedScope(scope), + ), + ) - expect(res.status).toEqual(200) - expect(res.body.verified).toEqual(true) - }) + await Promise.all( + testCase.apiScopes.map((scope) => factory.createApiScope(scope)), + ) - it(`POST ${path} returns non-verified response`, async () => { - res = await server.post(path).send({ - fromNationalId: nonExistingLegalRepresentativeNationalId, - delegationTypes: [AuthDelegationType.LegalRepresentative], + await factory.createDelegationIndexRecord({ + fromNationalId: nonExistingLegalRepresentativeNationalId, + toNationalId: testCase.user.nationalId, + type: AuthDelegationType.LegalRepresentative, + provider: AuthDelegationProvider.DistrictCommissionersRegistry, + }) + }) + + let res: request.Response + it(`POST ${path} returns verified response`, async () => { + res = await server.post(path).send({ + fromNationalId: testCase.fromLegalRepresentative[0], + delegationTypes: [AuthDelegationType.LegalRepresentative], + }) + + expect(res.status).toEqual(200) + expect(res.body.verified).toEqual(true) + }) + + it(`POST ${path} returns non-verified response`, async () => { + res = await server.post(path).send({ + fromNationalId: nonExistingLegalRepresentativeNationalId, + delegationTypes: [AuthDelegationType.LegalRepresentative], + }) + + expect(res.status).toEqual(200) + expect(res.body.verified).toEqual(false) + }) }) - - expect(res.status).toEqual(200) - expect(res.body.verified).toEqual(false) - }) - }) + }, + ) }) diff --git a/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts b/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts index 4abd4987bd31..f2dee16513d6 100644 --- a/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts +++ b/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts @@ -1,7 +1,7 @@ import { getModelToken } from '@nestjs/sequelize' +import addDays from 'date-fns/addDays' import request from 'supertest' import { uuid } from 'uuidv4' -import addDays from 'date-fns/addDays' import { ApiScope, @@ -12,8 +12,10 @@ import { DelegationScope, DelegationTypeModel, Domain, + NationalRegistryV3FeatureService, } from '@island.is/auth-api-lib' import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3' import { createClient, createDomain, @@ -33,246 +35,273 @@ import { defaultScopes, setupWithAuth } from '../../../../test/setup' import { getFakeNationalId } from '../../../../test/stubs/genericStubs' describe('DelegationsController', () => { - describe('Given a user is authenticated', () => { - let app: TestApp - let factory: FixtureFactory - let server: request.SuperTest - - let apiScopeModel: typeof ApiScope - let apiScopeDelegationTypeModel: typeof ApiScopeDelegationType - let delegationDelegationTypeModel: typeof DelegationDelegationType - let delegationModel: typeof Delegation - let delegationTypeModel: typeof DelegationTypeModel - let nationalRegistryApi: NationalRegistryClientService - let delegationProviderModel: typeof DelegationProviderModel - let delegationScopesModel: typeof DelegationScope - - const client = createClient({ - clientId: '@island.is/webapp', - }) - - const scopeValid1 = 'scope/valid1' - const scopeValid2 = 'scope/valid2' - const scopeValid1and2 = 'scope/valid1and2' - const scopeUnactiveType = 'scope/unactiveType' - const scopeOutdated = 'scope/outdated' - const disabledScope = 'disabledScope' - - client.allowedScopes = Object.values([ - scopeValid1, - scopeValid2, - scopeValid1and2, - scopeUnactiveType, - scopeOutdated, - disabledScope, - ]).map((s) => ({ - clientId: client.clientId, - scopeName: s, - })) - - const userNationalId = getFakeNationalId() - - const user = createCurrentUser({ - nationalId: userNationalId, - scope: [defaultScopes.testUserHasAccess.name], - client: client.clientId, - }) - - const domain = createDomain() - - beforeAll(async () => { - app = await setupWithAuth({ - user, - }) - server = request(app.getHttpServer()) - - const domainModel = app.get(getModelToken(Domain)) - await domainModel.create(domain) - - apiScopeModel = app.get(getModelToken(ApiScope)) - - apiScopeDelegationTypeModel = app.get( - getModelToken(ApiScopeDelegationType), - ) - delegationTypeModel = app.get( - getModelToken(DelegationTypeModel), - ) - delegationProviderModel = app.get( - getModelToken(DelegationProviderModel), - ) - delegationScopesModel = app.get( - getModelToken(DelegationScope), - ) - delegationModel = app.get(getModelToken(Delegation)) - delegationDelegationTypeModel = app.get( - getModelToken(DelegationDelegationType), - ) - nationalRegistryApi = app.get(NationalRegistryClientService) - factory = new FixtureFactory(app) - }) - - afterAll(async () => { - await app.cleanUp() - }) - - describe('GET with general mandate delegation type', () => { - const representeeNationalId = getFakeNationalId() - let nationalRegistryApiSpy: jest.SpyInstance - const scopeNames = [ - 'api-scope/generalMandate1', - 'api-scope/generalMandate2', - 'api-scope/generalMandate3', - ] - - beforeAll(async () => { - client.supportedDelegationTypes = [ - AuthDelegationType.GeneralMandate, - AuthDelegationType.LegalGuardian, - ] - await factory.createClient(client) - - const delegations = await delegationModel.create({ - id: uuid(), - fromDisplayName: 'Test', - fromNationalId: representeeNationalId, - toNationalId: userNationalId, - toName: 'Test', + describe.each([false, true])( + 'national registry v3 featureflag: %s', + (featureFlag) => { + describe('Given a user is authenticated', () => { + let app: TestApp + let factory: FixtureFactory + let server: request.SuperTest + + let apiScopeModel: typeof ApiScope + let apiScopeDelegationTypeModel: typeof ApiScopeDelegationType + let delegationDelegationTypeModel: typeof DelegationDelegationType + let delegationModel: typeof Delegation + let delegationTypeModel: typeof DelegationTypeModel + let nationalRegistryApi: NationalRegistryClientService + let nationalRegistryV3Api: NationalRegistryV3ClientService + let delegationProviderModel: typeof DelegationProviderModel + let delegationScopesModel: typeof DelegationScope + + const client = createClient({ + clientId: '@island.is/webapp', + }) + + const scopeValid1 = 'scope/valid1' + const scopeValid2 = 'scope/valid2' + const scopeValid1and2 = 'scope/valid1and2' + const scopeUnactiveType = 'scope/unactiveType' + const scopeOutdated = 'scope/outdated' + const disabledScope = 'disabledScope' + + client.allowedScopes = Object.values([ + scopeValid1, + scopeValid2, + scopeValid1and2, + scopeUnactiveType, + scopeOutdated, + disabledScope, + ]).map((s) => ({ + clientId: client.clientId, + scopeName: s, + })) + + const userNationalId = getFakeNationalId() + + const user = createCurrentUser({ + nationalId: userNationalId, + scope: [defaultScopes.testUserHasAccess.name], + client: client.clientId, }) - await delegationProviderModel.create({ - id: AuthDelegationProvider.Custom, - name: 'Custom', - description: 'Custom', + const domain = createDomain() + + beforeAll(async () => { + app = await setupWithAuth({ + user, + }) + server = request(app.getHttpServer()) + + const domainModel = app.get(getModelToken(Domain)) + await domainModel.create(domain) + + apiScopeModel = app.get(getModelToken(ApiScope)) + + apiScopeDelegationTypeModel = app.get( + getModelToken(ApiScopeDelegationType), + ) + delegationTypeModel = app.get( + getModelToken(DelegationTypeModel), + ) + delegationProviderModel = app.get( + getModelToken(DelegationProviderModel), + ) + delegationScopesModel = app.get( + getModelToken(DelegationScope), + ) + delegationModel = app.get( + getModelToken(Delegation), + ) + delegationDelegationTypeModel = app.get< + typeof DelegationDelegationType + >(getModelToken(DelegationDelegationType)) + nationalRegistryApi = app.get(NationalRegistryClientService) + nationalRegistryV3Api = app.get(NationalRegistryV3ClientService) + const nationalRegistryV3FeatureService = app.get( + NationalRegistryV3FeatureService, + ) + jest + .spyOn(nationalRegistryV3FeatureService, 'getValue') + .mockImplementation(async () => featureFlag) + factory = new FixtureFactory(app) }) - await delegationDelegationTypeModel.create({ - delegationId: delegations.id, - delegationTypeId: AuthDelegationType.GeneralMandate, + afterAll(async () => { + await app.cleanUp() }) - await apiScopeModel.bulkCreate( - scopeNames.map((name) => ({ - name, - domainName: domain.name, - enabled: true, - description: `${name}: description`, - displayName: `${name}: display name`, - })), - ) - - // set 2 of 3 scopes to have general mandate delegation type - await apiScopeDelegationTypeModel.bulkCreate([ - { - apiScopeName: scopeNames[0], - delegationType: AuthDelegationType.GeneralMandate, - }, - { - apiScopeName: scopeNames[1], - delegationType: AuthDelegationType.GeneralMandate, - }, - ]) - - nationalRegistryApiSpy = jest - .spyOn(nationalRegistryApi, 'getIndividual') - .mockImplementation(async (id) => { - const user = createNationalRegistryUser({ - nationalId: representeeNationalId, + describe('GET with general mandate delegation type', () => { + const representeeNationalId = getFakeNationalId() + let nationalRegistryApiSpy: jest.SpyInstance + let nationalRegistryV3ApiSpy: jest.SpyInstance + const scopeNames = [ + 'api-scope/generalMandate1', + 'api-scope/generalMandate2', + 'api-scope/generalMandate3', + ] + + beforeAll(async () => { + client.supportedDelegationTypes = [ + AuthDelegationType.GeneralMandate, + AuthDelegationType.LegalGuardian, + ] + await factory.createClient(client) + + const delegations = await delegationModel.create({ + id: uuid(), + fromDisplayName: 'Test', + fromNationalId: representeeNationalId, + toNationalId: userNationalId, + toName: 'Test', + }) + + await delegationProviderModel.create({ + id: AuthDelegationProvider.Custom, + name: 'Custom', + description: 'Custom', + }) + + await delegationDelegationTypeModel.create({ + delegationId: delegations.id, + delegationTypeId: AuthDelegationType.GeneralMandate, }) - return user ?? null + await apiScopeModel.bulkCreate( + scopeNames.map((name) => ({ + name, + domainName: domain.name, + enabled: true, + description: `${name}: description`, + displayName: `${name}: display name`, + })), + ) + + // set 2 of 3 scopes to have general mandate delegation type + await apiScopeDelegationTypeModel.bulkCreate([ + { + apiScopeName: scopeNames[0], + delegationType: AuthDelegationType.GeneralMandate, + }, + { + apiScopeName: scopeNames[1], + delegationType: AuthDelegationType.GeneralMandate, + }, + ]) + + nationalRegistryApiSpy = jest + .spyOn(nationalRegistryApi, 'getIndividual') + .mockImplementation(async (id) => { + const user = createNationalRegistryUser({ + nationalId: representeeNationalId, + }) + + return user ?? null + }) + + nationalRegistryV3ApiSpy = jest + .spyOn(nationalRegistryV3Api, 'getAllDataIndividual') + .mockImplementation(async () => { + const user = createNationalRegistryUser({ + nationalId: representeeNationalId, + }) + + return { kennitala: user.nationalId, nafn: user.name } + }) }) - }) - afterAll(async () => { - await app.cleanUp() - nationalRegistryApiSpy.mockClear() - }) + afterAll(async () => { + await app.cleanUp() + nationalRegistryApiSpy.mockClear() + nationalRegistryV3ApiSpy.mockClear() + }) - it('should return mergedDelegationDTO with the generalMandate', async () => { - const response = await server.get('/v2/delegations') + it('should return mergedDelegationDTO with the generalMandate', async () => { + const response = await server.get('/v2/delegations') - expect(response.status).toEqual(200) - expect(response.body).toHaveLength(1) - }) + expect(response.status).toEqual(200) + expect(response.body).toHaveLength(1) + }) - it('should get all general mandate scopes', async () => { - const response = await server.get('/delegations/scopes').query({ - fromNationalId: representeeNationalId, - delegationType: [AuthDelegationType.GeneralMandate], - }) + it('should get all general mandate scopes', async () => { + const response = await server.get('/delegations/scopes').query({ + fromNationalId: representeeNationalId, + delegationType: [AuthDelegationType.GeneralMandate], + }) - expect(response.status).toEqual(200) - expect(response.body).toEqual([scopeNames[0], scopeNames[1]]) - }) + expect(response.status).toEqual(200) + expect(response.body).toEqual([scopeNames[0], scopeNames[1]]) + }) - it('should only return valid general mandates', async () => { - const newNationalId = getFakeNationalId() - const newDelegation = await delegationModel.create({ - id: uuid(), - fromDisplayName: 'Test', - fromNationalId: newNationalId, - toNationalId: userNationalId, - toName: 'Test', - }) + it('should only return valid general mandates', async () => { + const newNationalId = getFakeNationalId() + const newDelegation = await delegationModel.create({ + id: uuid(), + fromDisplayName: 'Test', + fromNationalId: newNationalId, + toNationalId: userNationalId, + toName: 'Test', + }) - await delegationDelegationTypeModel.create({ - delegationId: newDelegation.id, - delegationTypeId: AuthDelegationType.GeneralMandate, - validTo: addDays(new Date(), -2), - }) + await delegationDelegationTypeModel.create({ + delegationId: newDelegation.id, + delegationTypeId: AuthDelegationType.GeneralMandate, + validTo: addDays(new Date(), -2), + }) - const response = await server.get('/delegations/scopes').query({ - fromNationalId: newNationalId, - delegationType: [AuthDelegationType.GeneralMandate], - }) + const response = await server.get('/delegations/scopes').query({ + fromNationalId: newNationalId, + delegationType: [AuthDelegationType.GeneralMandate], + }) - expect(response.status).toEqual(200) - expect(response.body).toEqual([]) - }) + expect(response.status).toEqual(200) + expect(response.body).toEqual([]) + }) - it('should return all general mandate scopes and other preset scopes', async () => { - const newDelegation = await delegationModel.create({ - id: uuid(), - fromDisplayName: 'Test', - fromNationalId: representeeNationalId, - domainName: domain.name, - toNationalId: userNationalId, - toName: 'Test', - }) + it('should return all general mandate scopes and other preset scopes', async () => { + const newDelegation = await delegationModel.create({ + id: uuid(), + fromDisplayName: 'Test', + fromNationalId: representeeNationalId, + domainName: domain.name, + toNationalId: userNationalId, + toName: 'Test', + }) - await delegationTypeModel.create({ - id: AuthDelegationType.Custom, - name: 'custom', - description: 'custom', - providerId: AuthDelegationProvider.Custom, - }) + await delegationTypeModel.create({ + id: AuthDelegationType.Custom, + name: 'custom', + description: 'custom', + providerId: AuthDelegationProvider.Custom, + }) - await delegationScopesModel.create({ - id: uuid(), - delegationId: newDelegation.id, - scopeName: scopeNames[2], - // set valid from as yesterday and valid to as tomorrow - validFrom: addDays(new Date(), -1), - validTo: addDays(new Date(), 1), - }) + await delegationScopesModel.create({ + id: uuid(), + delegationId: newDelegation.id, + scopeName: scopeNames[2], + // set valid from as yesterday and valid to as tomorrow + validFrom: addDays(new Date(), -1), + validTo: addDays(new Date(), 1), + }) - await apiScopeDelegationTypeModel.create({ - apiScopeName: scopeNames[2], - delegationType: AuthDelegationType.LegalGuardian, - }) + await apiScopeDelegationTypeModel.create({ + apiScopeName: scopeNames[2], + delegationType: AuthDelegationType.LegalGuardian, + }) - const response = await server.get('/delegations/scopes').query({ - fromNationalId: representeeNationalId, - delegationType: [ - AuthDelegationType.GeneralMandate, - AuthDelegationType.LegalGuardian, - ], - }) + const response = await server.get('/delegations/scopes').query({ + fromNationalId: representeeNationalId, + delegationType: [ + AuthDelegationType.GeneralMandate, + AuthDelegationType.LegalGuardian, + ], + }) - expect(response.status).toEqual(200) - expect(response.body).toEqual(expect.arrayContaining(scopeNames)) - expect(response.body).toHaveLength(scopeNames.length) + expect(response.status).toEqual(200) + expect(response.body).toEqual(expect.arrayContaining(scopeNames)) + expect(response.body).toHaveLength(scopeNames.length) + }) + }) }) - }) - }) + }, + ) }) diff --git a/apps/services/auth/personal-representative/infra/personal-representative.ts b/apps/services/auth/personal-representative/infra/personal-representative.ts index 180b75ec9891..42928e86db9f 100644 --- a/apps/services/auth/personal-representative/infra/personal-representative.ts +++ b/apps/services/auth/personal-representative/infra/personal-representative.ts @@ -1,5 +1,10 @@ import { json, service, ServiceBuilder } from '../../../../../infra/src/dsl/dsl' -import { Base, Client, RskProcuring } from '../../../../../infra/src/dsl/xroad' +import { + Base, + Client, + NationalRegistryAuthB2C, + RskProcuring, +} from '../../../../../infra/src/dsl/xroad' const REDIS_NODE_CONFIG = { dev: json([ @@ -62,8 +67,10 @@ export const serviceSetup = '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET', SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME', SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD', + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: + '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET', }) - .xroad(Base, Client, RskProcuring) + .xroad(Base, Client, RskProcuring, NationalRegistryAuthB2C) .ingress({ primary: { host: { diff --git a/apps/services/auth/personal-representative/src/app/app.module.ts b/apps/services/auth/personal-representative/src/app/app.module.ts index e46d8b36eeed..0c89f4dd3a5a 100644 --- a/apps/services/auth/personal-representative/src/app/app.module.ts +++ b/apps/services/auth/personal-representative/src/app/app.module.ts @@ -8,8 +8,10 @@ import { import { AuthModule } from '@island.is/auth-nest-tools' import { RskRelationshipsClientConfig } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientConfig } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-registry-v3' import { CompanyRegistryConfig } from '@island.is/clients/rsk/company-registry' import { SyslumennClientConfig } from '@island.is/clients/syslumenn' +import { ZendeskServiceConfig } from '@island.is/clients/zendesk' import { AuditModule } from '@island.is/nest/audit' import { ConfigModule, @@ -17,12 +19,11 @@ import { XRoadConfig, } from '@island.is/nest/config' import { FeatureFlagConfig } from '@island.is/nest/feature-flags' -import { ZendeskServiceConfig } from '@island.is/clients/zendesk' import { environment } from '../environments' -import { PersonalRepresentativeTypesModule } from './modules/personalRepresentativeTypes/personalRepresentativeTypes.module' import { AccessLogsModule } from './modules/accessLogs/accessLogs.module' import { PersonalRepresentativesModule } from './modules/personalRepresentatives/personalRepresentatives.module' +import { PersonalRepresentativeTypesModule } from './modules/personalRepresentativeTypes/personalRepresentativeTypes.module' import { RightTypesModule } from './modules/rightTypes/rightTypes.module' @Module({ @@ -38,6 +39,7 @@ import { RightTypesModule } from './modules/rightTypes/rightTypes.module' DelegationConfig, IdsClientConfig, NationalRegistryClientConfig, + NationalRegistryV3ClientConfig, RskRelationshipsClientConfig, CompanyRegistryConfig, XRoadConfig, diff --git a/apps/services/auth/public-api/infra/auth-public-api.ts b/apps/services/auth/public-api/infra/auth-public-api.ts index b27663150b40..609588c46271 100644 --- a/apps/services/auth/public-api/infra/auth-public-api.ts +++ b/apps/services/auth/public-api/infra/auth-public-api.ts @@ -1,5 +1,10 @@ import { json, service, ServiceBuilder } from '../../../../../infra/src/dsl/dsl' -import { Base, Client, RskProcuring } from '../../../../../infra/src/dsl/xroad' +import { + Base, + Client, + NationalRegistryAuthB2C, + RskProcuring, +} from '../../../../../infra/src/dsl/xroad' const REDIS_NODE_CONFIG = { dev: json([ @@ -86,8 +91,10 @@ export const serviceSetup = (): ServiceBuilder<'services-auth-public-api'> => { '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET', SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME', SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD', + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: + '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET', }) - .xroad(Base, Client, RskProcuring) + .xroad(Base, Client, RskProcuring, NationalRegistryAuthB2C) .ingress({ primary: { host: { diff --git a/apps/services/auth/public-api/src/app/app.module.ts b/apps/services/auth/public-api/src/app/app.module.ts index e3719da7e4b9..7fe7ba43de60 100644 --- a/apps/services/auth/public-api/src/app/app.module.ts +++ b/apps/services/auth/public-api/src/app/app.module.ts @@ -9,8 +9,10 @@ import { import { AuthModule } from '@island.is/auth-nest-tools' import { RskRelationshipsClientConfig } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientConfig } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-registry-v3' import { CompanyRegistryConfig } from '@island.is/clients/rsk/company-registry' import { SyslumennClientConfig } from '@island.is/clients/syslumenn' +import { ZendeskServiceConfig } from '@island.is/clients/zendesk' import { AuditModule } from '@island.is/nest/audit' import { ConfigModule, @@ -23,7 +25,6 @@ import { ProblemModule } from '@island.is/nest/problem' import { environment } from '../environments' import { DelegationsModule } from './modules/delegations/delegations.module' import { PasskeysModule } from './modules/passkeys/passkeys.module' -import { ZendeskServiceConfig } from '@island.is/clients/zendesk' @Module({ imports: [ @@ -42,6 +43,7 @@ import { ZendeskServiceConfig } from '@island.is/clients/zendesk' FeatureFlagConfig, IdsClientConfig, NationalRegistryClientConfig, + NationalRegistryV3ClientConfig, RskRelationshipsClientConfig, CompanyRegistryConfig, XRoadConfig, diff --git a/apps/services/auth/public-api/src/app/modules/delegations/actorDelegations.controller.spec.ts b/apps/services/auth/public-api/src/app/modules/delegations/actorDelegations.controller.spec.ts index d57f16dea37d..a7ad9fb47b1e 100644 --- a/apps/services/auth/public-api/src/app/modules/delegations/actorDelegations.controller.spec.ts +++ b/apps/services/auth/public-api/src/app/modules/delegations/actorDelegations.controller.spec.ts @@ -1,8 +1,8 @@ import { getModelToken } from '@nestjs/sequelize' +import addYears from 'date-fns/addYears' +import kennitala from 'kennitala' import times from 'lodash/times' import request from 'supertest' -import kennitala from 'kennitala' -import addYears from 'date-fns/addYears' import { ClientDelegationType, @@ -14,6 +14,7 @@ import { DelegationScope, DelegationTypeModel, MergedDelegationDTO, + NationalRegistryV3FeatureService, PersonalRepresentative, PersonalRepresentativeDelegationTypeModel, PersonalRepresentativeRight, @@ -26,6 +27,7 @@ import { IndividualDto, NationalRegistryClientService, } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3' import { createClient, createDelegation, @@ -153,923 +155,976 @@ beforeAll(() => { }) describe('ActorDelegationsController', () => { - describe('with auth', () => { - let app: TestApp - let server: request.SuperTest - let delegationModel: typeof Delegation - let delegationDelegationTypeModel: typeof DelegationDelegationType - let clientDelegationTypeModel: typeof ClientDelegationType - let nationalRegistryApi: NationalRegistryClientService - - beforeAll(async () => { - // TestApp setup with auth and database - app = await setupWithAuth({ - user, - userName, - nationalRegistryUser, - client: { - props: client, - scopes: Scopes.slice(0, 4).map((s) => s.name), - }, - }) - server = request(app.getHttpServer()) - - // Get reference on Delegation and Client models to seed DB - delegationModel = app.get(getModelToken(Delegation)) - clientDelegationTypeModel = app.get( - getModelToken(ClientDelegationType), - ) - delegationDelegationTypeModel = app.get( - getModelToken(DelegationDelegationType), - ) - nationalRegistryApi = app.get(NationalRegistryClientService) - }) - - beforeEach(async () => { - return await clientDelegationTypeModel.bulkCreate( - delegationTypes.map((type) => ({ - clientId: client.clientId, - delegationType: type, - })), - { - updateOnDuplicate: ['modified'], - }, - ) - }) - - afterAll(async () => { - await app.cleanUp() - }) - - afterEach(async () => { - await delegationModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - }) - - describe('GET /actor/delegations', () => { - let nationalRegistryApiSpy: jest.SpyInstance - const path = '/v1/actor/delegations' - const query = '?direction=incoming' - const deceasedNationalIds = times(3, () => createNationalId('person')) - const nationalRegistryUsers = [ - nationalRegistryUser, - ...Object.values(mockDelegations).map((delegation) => - createNationalRegistryUser({ - nationalId: delegation.fromNationalId, - }), - ), - ...times(10, () => - createNationalRegistryUser({ - name: getFakeName(), - nationalId: createNationalId('person'), - }), - ), - ] - - beforeAll(async () => { - nationalRegistryApiSpy = jest - .spyOn(nationalRegistryApi, 'getIndividual') - .mockImplementation(async (id) => { - if (deceasedNationalIds.includes(id)) { - return null - } + describe.each([false, true])( + 'national registry v3 featureflag: %s', + (featureFlag) => { + describe('with auth', () => { + let app: TestApp + let server: request.SuperTest + let delegationModel: typeof Delegation + let delegationDelegationTypeModel: typeof DelegationDelegationType + let clientDelegationTypeModel: typeof ClientDelegationType + let nationalRegistryApi: NationalRegistryClientService + let nationalRegistryV3Api: NationalRegistryV3ClientService - const user = nationalRegistryUsers.find((u) => u?.nationalId === id) - - return user ?? null + beforeAll(async () => { + // TestApp setup with auth and database + app = await setupWithAuth({ + user, + userName, + nationalRegistryUser, + client: { + props: client, + scopes: Scopes.slice(0, 4).map((s) => s.name), + }, }) - }) - - it('should return only valid delegations', async () => { - // Arrange - await createDelegationModels( - delegationModel, - Object.values(mockDelegations), - ) - const expectedModels = await findExpectedMergedDelegationModels( - delegationModel, - [ - mockDelegations.incoming.id, - mockDelegations.incomingBothValidAndNotAllowed.id, - mockDelegations.incomingWithOtherDomain.id, - ], - [Scopes[0].name], - ) - - // Act - const res = await server.get(`${path}${query}`) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(3) - expectMatchingMergedDelegations( - res.body, - updateDelegationFromNameToPersonName( - expectedModels, - nationalRegistryUsers, - ), - ) - }) + server = request(app.getHttpServer()) - it('should return only delegations with scopes the client has access to', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incomingWithOtherDomain, - ]) - const expectedModel = await findExpectedMergedDelegationModels( - delegationModel, - mockDelegations.incomingWithOtherDomain.id, - [Scopes[0].name], - ) - - // Act - const res = await server.get(`${path}${query}`) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expectMatchingMergedDelegations( - res.body[0], - updateDelegationFromNameToPersonName( - expectedModel, - nationalRegistryUsers, - ), - ) - }) - - it('should return custom delegations and general mandate when the delegationTypes filter has both types and delegation exists for both', async () => { - // Arrange - const delegation = createDelegation({ - fromNationalId: nationalRegistryUser.nationalId, - toNationalId: user.nationalId, - scopes: [], + // Get reference on Delegation and Client models to seed DB + delegationModel = app.get( + getModelToken(Delegation), + ) + clientDelegationTypeModel = app.get( + getModelToken(ClientDelegationType), + ) + delegationDelegationTypeModel = app.get< + typeof DelegationDelegationType + >(getModelToken(DelegationDelegationType)) + nationalRegistryApi = app.get(NationalRegistryClientService) + nationalRegistryV3Api = app.get(NationalRegistryV3ClientService) }) - await delegationModel.create(delegation) - - await delegationDelegationTypeModel.create({ - delegationId: delegation.id, - delegationTypeId: AuthDelegationType.GeneralMandate, + beforeEach(async () => { + return await clientDelegationTypeModel.bulkCreate( + delegationTypes.map((type) => ({ + clientId: client.clientId, + delegationType: type, + })), + { + updateOnDuplicate: ['modified'], + }, + ) }) - await createDelegationModels(delegationModel, [ - mockDelegations.incomingWithOtherDomain, - ]) - - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.GeneralMandate}`, - ) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(2) - expect( - res.body - .map((d: MergedDelegationDTO) => d.types) - .flat() - .sort(), - ).toEqual( - [AuthDelegationType.Custom, AuthDelegationType.GeneralMandate].sort(), - ) - }) - - it('should return a merged object with both Custom and GeneralMandate types', async () => { - // Arrange - const delegation = createDelegation({ - fromNationalId: - mockDelegations.incomingWithOtherDomain.fromNationalId, - toNationalId: user.nationalId, - domainName: null, - scopes: [], + afterAll(async () => { + await app.cleanUp() }) - await delegationModel.create(delegation) - - await delegationDelegationTypeModel.create({ - delegationId: delegation.id, - delegationTypeId: AuthDelegationType.GeneralMandate, + afterEach(async () => { + await delegationModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) }) - await createDelegationModels(delegationModel, [ - mockDelegations.incomingWithOtherDomain, - ]) - - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.GeneralMandate}`, - ) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expect( - res.body - .map((d: MergedDelegationDTO) => d.types) - .flat() - .sort(), - ).toEqual( - [AuthDelegationType.Custom, AuthDelegationType.GeneralMandate].sort(), - ) - }) - - it('should return only delegations related to the provided otherUser national id', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incoming, - ]) - const expectedModel = await findExpectedMergedDelegationModels( - delegationModel, - mockDelegations.incoming.id, - [Scopes[0].name], - ) - - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&otherUser=${mockDelegations.incoming.fromNationalId}`, - ) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expectMatchingMergedDelegations( - res.body[0], - updateDelegationFromNameToPersonName( - expectedModel, - nationalRegistryUsers, - ), - ) - }) - - it('should return only delegations related to the provided otherUser national id without the general mandate since there is none', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incoming, - ]) - const expectedModel = await findExpectedMergedDelegationModels( - delegationModel, - mockDelegations.incoming.id, - [Scopes[0].name], - ) - - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes${AuthDelegationType.GeneralMandate}&otherUser=${mockDelegations.incoming.fromNationalId}`, - ) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expectMatchingMergedDelegations( - res.body[0], - updateDelegationFromNameToPersonName( - expectedModel, - nationalRegistryUsers, - ), - ) - }) - - it('should return empty array when provided otherUser national id is not related to any delegation', async () => { - // Arrange - const unrelatedNationalId = createNationalId('person') + describe('GET /actor/delegations', () => { + let nationalRegistryApiSpy: jest.SpyInstance + let nationalRegistryV3ApiSpy: jest.SpyInstance + const path = '/v1/actor/delegations' + const query = '?direction=incoming' + const deceasedNationalIds = times(3, () => createNationalId('person')) + const nationalRegistryUsers = [ + nationalRegistryUser, + ...Object.values(mockDelegations).map((delegation) => + createNationalRegistryUser({ + nationalId: delegation.fromNationalId, + }), + ), + ...times(10, () => + createNationalRegistryUser({ + name: getFakeName(), + nationalId: createNationalId('person'), + }), + ), + ] - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&otherUser=${unrelatedNationalId}`, - ) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) - - it('should return custom delegations and not deceased delegations, when the delegationTypes filter is custom type', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incoming, - createDelegation({ - fromNationalId: deceasedNationalIds[0], - toNationalId: user.nationalId, - scopes: [Scopes[0].name], - today, - }), - createDelegation({ - fromNationalId: deceasedNationalIds[1], - toNationalId: user.nationalId, - scopes: [Scopes[0].name], - today, - }), - ]) - - // We expect the first model to be returned, but not the second or third since they are tied to a deceased person - const expectedModel = await findExpectedMergedDelegationModels( - delegationModel, - mockDelegations.incoming.id, - [Scopes[0].name], - ) + beforeAll(async () => { + nationalRegistryApiSpy = jest + .spyOn(nationalRegistryApi, 'getIndividual') + .mockImplementation(async (id) => { + if (deceasedNationalIds.includes(id)) { + return null + } + + const user = nationalRegistryUsers.find( + (u) => u?.nationalId === id, + ) + + return user ?? null + }) + const nationalRegistryV3FeatureService = app.get( + NationalRegistryV3FeatureService, + ) + jest + .spyOn(nationalRegistryV3FeatureService, 'getValue') + .mockImplementation(async () => featureFlag) + nationalRegistryV3ApiSpy = jest + .spyOn(nationalRegistryV3Api, 'getAllDataIndividual') + .mockImplementation(async (id) => { + if (deceasedNationalIds.includes(id)) { + return { + kennitala: id, + afdrif: 'LÉST', + } + } + + const user = nationalRegistryUsers.find( + (u) => u?.nationalId === id, + ) + + return user + ? { + kennitala: id, + nafn: user?.name, + afdrif: null, + } + : null + }) + }) - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, - ) + it('should return only valid delegations', async () => { + // Arrange + await createDelegationModels( + delegationModel, + Object.values(mockDelegations), + ) + const expectedModels = await findExpectedMergedDelegationModels( + delegationModel, + [ + mockDelegations.incoming.id, + mockDelegations.incomingBothValidAndNotAllowed.id, + mockDelegations.incomingWithOtherDomain.id, + ], + [Scopes[0].name], + ) + + // Act + const res = await server.get(`${path}${query}`) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(3) + expectMatchingMergedDelegations( + res.body, + updateDelegationFromNameToPersonName( + expectedModels, + nationalRegistryUsers, + ), + ) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expectMatchingMergedDelegations( - res.body[0], - updateDelegationFromNameToPersonName( - expectedModel, - nationalRegistryUsers, - ), - ) - - // Verify - const expectedModifiedModels = await delegationModel.findAll({ - where: { - toNationalId: user.nationalId, - }, - include: [ - { - model: DelegationScope, - as: 'delegationScopes', - }, - ], - }) + it('should return only delegations with scopes the client has access to', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incomingWithOtherDomain, + ]) + const expectedModel = await findExpectedMergedDelegationModels( + delegationModel, + mockDelegations.incomingWithOtherDomain.id, + [Scopes[0].name], + ) + + // Act + const res = await server.get(`${path}${query}`) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expectMatchingMergedDelegations( + res.body[0], + updateDelegationFromNameToPersonName( + expectedModel, + nationalRegistryUsers, + ), + ) + }) - expect(expectedModifiedModels.length).toEqual(1) - }) + it('should return custom delegations and general mandate when the delegationTypes filter has both types and delegation exists for both', async () => { + // Arrange + const delegation = createDelegation({ + fromNationalId: nationalRegistryUser.nationalId, + toNationalId: user.nationalId, + scopes: [], + }) - it('should not mix up companies and individuals when processing deceased delegations [BUG]', async () => { - // Arrange - const incomingCompany = createDelegation({ - fromNationalId: createNationalId('company'), - toNationalId: user.nationalId, - scopes: [Scopes[0].name], - today, - }) - await createDelegationModels(delegationModel, [ - // The order of these is important to trigger the previous bug. - incomingCompany, - mockDelegations.incoming, - ]) - - // We expect both models to be returned. - const expectedModels = await findExpectedMergedDelegationModels( - delegationModel, - [mockDelegations.incoming.id, incomingCompany.id], - [Scopes[0].name], - ) + await delegationModel.create(delegation) - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, - ) + await delegationDelegationTypeModel.create({ + delegationId: delegation.id, + delegationTypeId: AuthDelegationType.GeneralMandate, + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(2) - expectMatchingMergedDelegations( - res.body, - updateDelegationFromNameToPersonName( - expectedModels, - nationalRegistryUsers, - ), - ) - }) + await createDelegationModels(delegationModel, [ + mockDelegations.incomingWithOtherDomain, + ]) - it('should return delegations which only has scopes with special scope rules [BUG]', async () => { - // Arrange - const specialDelegation = createDelegation({ - fromNationalId: nationalRegistryUser.nationalId, - toNationalId: user.nationalId, - scopes: [Scopes[3].name], - today, - }) - await createDelegationModels(delegationModel, [specialDelegation]) + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.GeneralMandate}`, + ) - // We expect the delegation to be returned. - const expectedModels = await findExpectedMergedDelegationModels( - delegationModel, - [specialDelegation.id], - ) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(2) + expect( + res.body + .map((d: MergedDelegationDTO) => d.types) + .flat() + .sort(), + ).toEqual( + [ + AuthDelegationType.Custom, + AuthDelegationType.GeneralMandate, + ].sort(), + ) + }) - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, - ) + it('should return a merged object with both Custom and GeneralMandate types', async () => { + // Arrange + const delegation = createDelegation({ + fromNationalId: + mockDelegations.incomingWithOtherDomain.fromNationalId, + toNationalId: user.nationalId, + domainName: null, + scopes: [], + }) - // Assert - expect(res.status).toEqual(200) - expectMatchingMergedDelegations( - res.body, - updateDelegationFromNameToPersonName( - expectedModels, - nationalRegistryUsers, - ), - ) - }) + await delegationModel.create(delegation) - it('should return delegations when the delegationTypes filter is empty', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incomingWithOtherDomain, - ]) - const expectedModel = await findExpectedMergedDelegationModels( - delegationModel, - mockDelegations.incomingWithOtherDomain.id, - [Scopes[0].name], - ) + await delegationDelegationTypeModel.create({ + delegationId: delegation.id, + delegationTypeId: AuthDelegationType.GeneralMandate, + }) - // Act - const res = await server.get(`${path}${query}&delegationTypes=`) + await createDelegationModels(delegationModel, [ + mockDelegations.incomingWithOtherDomain, + ]) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expectMatchingMergedDelegations( - res.body[0], - updateDelegationFromNameToPersonName( - expectedModel, - nationalRegistryUsers, - ), - ) - }) + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.GeneralMandate}`, + ) - it('should not return custom delegations when the delegationTypes filter does not include custom type', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incomingWithOtherDomain, - ]) - - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.ProcurationHolder}`, - ) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expect( + res.body + .map((d: MergedDelegationDTO) => d.types) + .flat() + .sort(), + ).toEqual( + [ + AuthDelegationType.Custom, + AuthDelegationType.GeneralMandate, + ].sort(), + ) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + it('should return only delegations related to the provided otherUser national id', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incoming, + ]) + const expectedModel = await findExpectedMergedDelegationModels( + delegationModel, + mockDelegations.incoming.id, + [Scopes[0].name], + ) + + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&otherUser=${mockDelegations.incoming.fromNationalId}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expectMatchingMergedDelegations( + res.body[0], + updateDelegationFromNameToPersonName( + expectedModel, + nationalRegistryUsers, + ), + ) + }) - it('should return no delegation when the client does not have access to any scope', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incomingOnlyOtherDomain, - ]) + it('should return only delegations related to the provided otherUser national id without the general mandate since there is none', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incoming, + ]) + const expectedModel = await findExpectedMergedDelegationModels( + delegationModel, + mockDelegations.incoming.id, + [Scopes[0].name], + ) + + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes${AuthDelegationType.GeneralMandate}&otherUser=${mockDelegations.incoming.fromNationalId}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expectMatchingMergedDelegations( + res.body[0], + updateDelegationFromNameToPersonName( + expectedModel, + nationalRegistryUsers, + ), + ) + }) - // Act - const res = await server.get(`${path}${query}`) + it('should return empty array when provided otherUser national id is not related to any delegation', async () => { + // Arrange + const unrelatedNationalId = createNationalId('person') - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&otherUser=${unrelatedNationalId}`, + ) - it('should return 400 BadRequest if required query paramter is missing', async () => { - // Act - const res = await server.get(path) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) - // Assert - expect(res.status).toEqual(400) - expect(res.body).toMatchObject({ - status: 400, - type: 'https://httpstatuses.org/400', - title: 'Bad Request', - detail: - "'direction' can only be set to incoming for the /actor alias", - }) - }) + it('should return custom delegations and not deceased delegations, when the delegationTypes filter is custom type', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incoming, + createDelegation({ + fromNationalId: deceasedNationalIds[0], + toNationalId: user.nationalId, + scopes: [Scopes[0].name], + today, + }), + createDelegation({ + fromNationalId: deceasedNationalIds[1], + toNationalId: user.nationalId, + scopes: [Scopes[0].name], + today, + }), + ]) + + // We expect the first model to be returned, but not the second or third since they are tied to a deceased person + const expectedModel = await findExpectedMergedDelegationModels( + delegationModel, + mockDelegations.incoming.id, + [Scopes[0].name], + ) + + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expectMatchingMergedDelegations( + res.body[0], + updateDelegationFromNameToPersonName( + expectedModel, + nationalRegistryUsers, + ), + ) + + // Verify + const expectedModifiedModels = await delegationModel.findAll({ + where: { + toNationalId: user.nationalId, + }, + include: [ + { + model: DelegationScope, + as: 'delegationScopes', + }, + ], + }) - it('should not return delegation with no scope longer allowed for delegation', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incomingWithNoAllowed, - ]) + expect(expectedModifiedModels.length).toEqual(1) + }) - // Act - const res = await server.get(`${path}${query}`) + it('should not mix up companies and individuals when processing deceased delegations [BUG]', async () => { + // Arrange + const incomingCompany = createDelegation({ + fromNationalId: createNationalId('company'), + toNationalId: user.nationalId, + scopes: [Scopes[0].name], + today, + }) + await createDelegationModels(delegationModel, [ + // The order of these is important to trigger the previous bug. + incomingCompany, + mockDelegations.incoming, + ]) + + // We expect both models to be returned. + const expectedModels = await findExpectedMergedDelegationModels( + delegationModel, + [mockDelegations.incoming.id, incomingCompany.id], + [Scopes[0].name], + ) + + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(2) + expectMatchingMergedDelegations( + res.body, + updateDelegationFromNameToPersonName( + expectedModels, + nationalRegistryUsers, + ), + ) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + it('should return delegations which only has scopes with special scope rules [BUG]', async () => { + // Arrange + const specialDelegation = createDelegation({ + fromNationalId: nationalRegistryUser.nationalId, + toNationalId: user.nationalId, + scopes: [Scopes[3].name], + today, + }) + await createDelegationModels(delegationModel, [specialDelegation]) + + // We expect the delegation to be returned. + const expectedModels = await findExpectedMergedDelegationModels( + delegationModel, + [specialDelegation.id], + ) + + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, + ) + + // Assert + expect(res.status).toEqual(200) + expectMatchingMergedDelegations( + res.body, + updateDelegationFromNameToPersonName( + expectedModels, + nationalRegistryUsers, + ), + ) + }) - it('should not return delegation when client does not support custom delegations', async () => { - // Arrange - await createDelegationModels(delegationModel, [ - mockDelegations.incoming, - ]) - await clientDelegationTypeModel.destroy({ - where: { - clientId: client.clientId, - delegationType: AuthDelegationType.Custom, - }, - }) + it('should return delegations when the delegationTypes filter is empty', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incomingWithOtherDomain, + ]) + const expectedModel = await findExpectedMergedDelegationModels( + delegationModel, + mockDelegations.incomingWithOtherDomain.id, + [Scopes[0].name], + ) + + // Act + const res = await server.get(`${path}${query}&delegationTypes=`) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expectMatchingMergedDelegations( + res.body[0], + updateDelegationFromNameToPersonName( + expectedModel, + nationalRegistryUsers, + ), + ) + }) - // Act - const res = await server.get(`${path}${query}`) + it('should not return custom delegations when the delegationTypes filter does not include custom type', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incomingWithOtherDomain, + ]) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.ProcurationHolder}`, + ) - describe('with legal guardian delegations', () => { - let getForsja: jest.SpyInstance - let clientInstance: any - - const mockForKt = (kt: string): void => { - jest.spyOn(kennitala, 'info').mockReturnValue({ - kt, - age: 16, - birthday: addYears(Date.now(), -15), - birthdayReadable: '', - type: 'person', - valid: true, + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) }) - jest.spyOn(clientInstance, 'getIndividual').mockResolvedValueOnce({ - nationalId: kt, - name: nationalRegistryUser.name, - } as IndividualDto) - - jest - .spyOn(clientInstance, 'getCustodyChildren') - .mockResolvedValueOnce([kt]) - } - - beforeEach(() => { - clientInstance = app.get(NationalRegistryClientService) - getForsja = jest - .spyOn(clientInstance, 'getCustodyChildren') - .mockResolvedValue([nationalRegistryUser.nationalId]) - }) - - afterAll(() => { - getForsja.mockRestore() - }) + it('should return no delegation when the client does not have access to any scope', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incomingOnlyOtherDomain, + ]) - it('should return delegations', async () => { - const kt = '1111089030' - - // Arrange - mockForKt(kt) - - const expectedDelegation = DelegationDTOMapper.toMergedDelegationDTO({ - fromName: nationalRegistryUser.name, - fromNationalId: kt, - provider: AuthDelegationProvider.NationalRegistry, - toNationalId: user.nationalId, - type: [ - AuthDelegationType.LegalGuardian, - AuthDelegationType.LegalGuardianMinor, - ], - } as Omit & { type: AuthDelegationType | AuthDelegationType[] }) - - // Act - const res = await server.get(`${path}${query}`) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expect(res.body[0]).toEqual(expectedDelegation) - }) + // Act + const res = await server.get(`${path}${query}`) - it('should not return delegations when client does not support legal guardian delegations', async () => { - await clientDelegationTypeModel.destroy({ - where: { - clientId: client.clientId, - delegationType: AuthDelegationType.LegalGuardian, - }, + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) }) - // Act - const res = await server.get(`${path}${query}`) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) - - it('should return a legal guardian delegation since the type is included in the delegationTypes filter', async () => { - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.LegalGuardian}`, - ) + it('should return 400 BadRequest if required query paramter is missing', async () => { + // Act + const res = await server.get(path) + + // Assert + expect(res.status).toEqual(400) + expect(res.body).toMatchObject({ + status: 400, + type: 'https://httpstatuses.org/400', + title: 'Bad Request', + detail: + "'direction' can only be set to incoming for the /actor alias", + }) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expect(res.body[0].types[0]).toEqual(AuthDelegationType.LegalGuardian) - }) + it('should not return delegation with no scope longer allowed for delegation', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incomingWithNoAllowed, + ]) - it('should not return a legal guardian delegation since the type is not included in the delegationTypes filter', async () => { - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, - ) + // Act + const res = await server.get(`${path}${query}`) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) - }) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) - describe('with procuring delegations', () => { - let getIndividualRelationships: jest.SpyInstance - beforeAll(() => { - const client = app.get(RskRelationshipsClient) - getIndividualRelationships = jest - .spyOn(client, 'getIndividualRelationships') - .mockResolvedValue({ - name: nationalRegistryUser.name, - nationalId: nationalRegistryUser.nationalId, - relationships: [ - { - nationalId: nationalRegistryUser.nationalId, - name: nationalRegistryUser.name, - }, - ], + it('should not return delegation when client does not support custom delegations', async () => { + // Arrange + await createDelegationModels(delegationModel, [ + mockDelegations.incoming, + ]) + await clientDelegationTypeModel.destroy({ + where: { + clientId: client.clientId, + delegationType: AuthDelegationType.Custom, + }, }) - }) - afterAll(() => { - getIndividualRelationships.mockRestore() - }) + // Act + const res = await server.get(`${path}${query}`) - it('should return delegations', async () => { - // Arrange - const expectedDelegation = { - fromName: nationalRegistryUser.name, - fromNationalId: nationalRegistryUser.nationalId, - provider: 'fyrirtaekjaskra', - toNationalId: user.nationalId, - type: 'ProcurationHolder', - } as DelegationDTO - // Act - const res = await server.get(`${path}${query}`) - - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expect(res.body[0]).toEqual( - DelegationDTOMapper.toMergedDelegationDTO(expectedDelegation), - ) - }) - - it('should not return delegations when client does not support procuring holder delegations', async () => { - // Arrange - await clientDelegationTypeModel.destroy({ - where: { - clientId: client.clientId, - delegationType: AuthDelegationType.ProcurationHolder, - }, + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) }) - // Act - const res = await server.get(`${path}${query}`) + describe('with legal guardian delegations', () => { + let getForsja: jest.SpyInstance + let clientInstance: any + + const mockForKt = (kt: string): void => { + jest.spyOn(kennitala, 'info').mockReturnValue({ + kt, + age: 16, + birthday: addYears(Date.now(), -15), + birthdayReadable: '', + type: 'person', + valid: true, + }) + + jest + .spyOn(clientInstance, 'getIndividual') + .mockResolvedValueOnce({ + nationalId: kt, + name: nationalRegistryUser.name, + } as IndividualDto) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + jest + .spyOn(clientInstance, 'getCustodyChildren') + .mockResolvedValueOnce([kt]) + } - it('should return a procuring holder delegation since the type is included in the delegationTypes filter', async () => { - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.ProcurationHolder}&delegationTypes=${AuthDelegationType.PersonalRepresentative}`, - ) + beforeEach(() => { + clientInstance = app.get(NationalRegistryClientService) + getForsja = jest + .spyOn(clientInstance, 'getCustodyChildren') + .mockResolvedValue([nationalRegistryUser.nationalId]) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expect(res.body[0].types[0]).toEqual( - AuthDelegationType.ProcurationHolder, - ) - }) + afterAll(() => { + getForsja.mockRestore() + }) - it('should not return a procuring holder delegation since the type is not included in the delegationTypes filter', async () => { - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.LegalGuardian}&delegationTypes=${AuthDelegationType.PersonalRepresentative}`, - ) + it('should return delegations', async () => { + const kt = '1111089030' + + // Arrange + mockForKt(kt) + + const expectedDelegation = + DelegationDTOMapper.toMergedDelegationDTO({ + fromName: nationalRegistryUser.name, + fromNationalId: kt, + provider: AuthDelegationProvider.NationalRegistry, + toNationalId: user.nationalId, + type: [ + AuthDelegationType.LegalGuardian, + AuthDelegationType.LegalGuardianMinor, + ], + } as Omit & { type: AuthDelegationType | AuthDelegationType[] }) + + // Act + const res = await server.get(`${path}${query}`) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expect(res.body[0]).toEqual(expectedDelegation) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) - }) + it('should not return delegations when client does not support legal guardian delegations', async () => { + await clientDelegationTypeModel.destroy({ + where: { + clientId: client.clientId, + delegationType: AuthDelegationType.LegalGuardian, + }, + }) - describe('when user is a personal representative with one representee', () => { - let prModel: typeof PersonalRepresentative - let prRightsModel: typeof PersonalRepresentativeRight - let prRightTypeModel: typeof PersonalRepresentativeRightType - let prTypeModel: typeof PersonalRepresentativeType - let delegationTypeModel: typeof DelegationTypeModel - let delegationProviderModel: typeof DelegationProviderModel - let prDelegationTypeModel: typeof PersonalRepresentativeDelegationTypeModel + // Act + const res = await server.get(`${path}${query}`) - beforeAll(async () => { - prTypeModel = app.get( - 'PersonalRepresentativeTypeRepository', - ) - prModel = app.get( - 'PersonalRepresentativeRepository', - ) - prRightTypeModel = app.get( - 'PersonalRepresentativeRightTypeRepository', - ) - prRightsModel = app.get( - 'PersonalRepresentativeRightRepository', - ) - delegationTypeModel = app.get( - getModelToken(DelegationTypeModel), - ) - delegationProviderModel = app.get( - getModelToken(DelegationProviderModel), - ) - prDelegationTypeModel = app.get< - typeof PersonalRepresentativeDelegationTypeModel - >(getModelToken(PersonalRepresentativeDelegationTypeModel)) - - const prType = await prTypeModel.create({ - code: 'prTypeCode', - name: 'prTypeName', - description: 'prTypeDescription', - }) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) - const pr = await prModel.create({ - nationalIdPersonalRepresentative: user.nationalId, - nationalIdRepresentedPerson: nationalRegistryUser.nationalId, - personalRepresentativeTypeCode: prType.code, - contractId: '1', - externalUserId: '1', - }) + it('should return a legal guardian delegation since the type is included in the delegationTypes filter', async () => { + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.LegalGuardian}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expect(res.body[0].types[0]).toEqual( + AuthDelegationType.LegalGuardian, + ) + }) - const dt = await delegationTypeModel.create({ - id: getPersonalRepresentativeDelegationType('prRightType'), - providerId: AuthDelegationProvider.PersonalRepresentativeRegistry, - name: `Personal Representative: prRightType`, - description: `Personal representative delegation type for right type prRightType`, - }) + it('should not return a legal guardian delegation since the type is not included in the delegationTypes filter', async () => { + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}`, + ) - const prRightType = await prRightTypeModel.create({ - code: 'prRightType', - description: 'prRightTypeDescription', + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) }) - const prr = await prRightsModel.create({ - rightTypeCode: prRightType.code, - personalRepresentativeId: pr.id, - }) + describe('with procuring delegations', () => { + let getIndividualRelationships: jest.SpyInstance + beforeAll(() => { + const client = app.get(RskRelationshipsClient) + getIndividualRelationships = jest + .spyOn(client, 'getIndividualRelationships') + .mockResolvedValue({ + name: nationalRegistryUser.name, + nationalId: nationalRegistryUser.nationalId, + relationships: [ + { + nationalId: nationalRegistryUser.nationalId, + name: nationalRegistryUser.name, + }, + ], + }) + }) - await prDelegationTypeModel.create({ - id: prr.id, - delegationTypeId: dt.id, - personalRepresentativeId: pr.id, - }) - }) + afterAll(() => { + getIndividualRelationships.mockRestore() + }) - describe('when fetched', () => { - let response: request.Response - let body: MergedDelegationDTO[] + it('should return delegations', async () => { + // Arrange + const expectedDelegation = { + fromName: nationalRegistryUser.name, + fromNationalId: nationalRegistryUser.nationalId, + provider: 'fyrirtaekjaskra', + toNationalId: user.nationalId, + type: 'ProcurationHolder', + } as DelegationDTO + // Act + const res = await server.get(`${path}${query}`) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expect(res.body[0]).toEqual( + DelegationDTOMapper.toMergedDelegationDTO(expectedDelegation), + ) + }) - beforeAll(async () => { - response = await server.get(`${path}${query}`) - body = response.body - }) + it('should not return delegations when client does not support procuring holder delegations', async () => { + // Arrange + await clientDelegationTypeModel.destroy({ + where: { + clientId: client.clientId, + delegationType: AuthDelegationType.ProcurationHolder, + }, + }) - it('should have a an OK return status', () => { - expect(response.status).toEqual(200) - }) + // Act + const res = await server.get(`${path}${query}`) - it('should return a single entity', () => { - expect(body.length).toEqual(1) - }) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) - it('should have the nationalId of the user as the representer', () => { - expect( - body.some((d) => d.toNationalId === user.nationalId), - ).toBeTruthy() - }) + it('should return a procuring holder delegation since the type is included in the delegationTypes filter', async () => { + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.ProcurationHolder}&delegationTypes=${AuthDelegationType.PersonalRepresentative}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expect(res.body[0].types[0]).toEqual( + AuthDelegationType.ProcurationHolder, + ) + }) - it('should have the nationalId of the correct representee', () => { - expect( - body.some( - (d) => d.fromNationalId === nationalRegistryUser.nationalId, - ), - ).toBeTruthy() - }) + it('should not return a procuring holder delegation since the type is not included in the delegationTypes filter', async () => { + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.LegalGuardian}&delegationTypes=${AuthDelegationType.PersonalRepresentative}`, + ) - it('should have the name of the correct representee', () => { - expect( - body.some((d) => d.fromName === nationalRegistryUser.name), - ).toBeTruthy() + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) }) - it('should have the delegation type claim of PersonalRepresentative', () => { - expect( - body.some( - (d) => d.types[0] === AuthDelegationType.PersonalRepresentative, - ), - ).toBeTruthy() - }) - }) + describe('when user is a personal representative with one representee', () => { + let prModel: typeof PersonalRepresentative + let prRightsModel: typeof PersonalRepresentativeRight + let prRightTypeModel: typeof PersonalRepresentativeRightType + let prTypeModel: typeof PersonalRepresentativeType + let delegationTypeModel: typeof DelegationTypeModel + let delegationProviderModel: typeof DelegationProviderModel + let prDelegationTypeModel: typeof PersonalRepresentativeDelegationTypeModel + + beforeAll(async () => { + prTypeModel = app.get( + 'PersonalRepresentativeTypeRepository', + ) + prModel = app.get( + 'PersonalRepresentativeRepository', + ) + prRightTypeModel = app.get< + typeof PersonalRepresentativeRightType + >('PersonalRepresentativeRightTypeRepository') + prRightsModel = app.get( + 'PersonalRepresentativeRightRepository', + ) + delegationTypeModel = app.get( + getModelToken(DelegationTypeModel), + ) + delegationProviderModel = app.get( + getModelToken(DelegationProviderModel), + ) + prDelegationTypeModel = app.get< + typeof PersonalRepresentativeDelegationTypeModel + >(getModelToken(PersonalRepresentativeDelegationTypeModel)) + + const prType = await prTypeModel.create({ + code: 'prTypeCode', + name: 'prTypeName', + description: 'prTypeDescription', + }) + + const pr = await prModel.create({ + nationalIdPersonalRepresentative: user.nationalId, + nationalIdRepresentedPerson: nationalRegistryUser.nationalId, + personalRepresentativeTypeCode: prType.code, + contractId: '1', + externalUserId: '1', + }) + + const dt = await delegationTypeModel.create({ + id: getPersonalRepresentativeDelegationType('prRightType'), + providerId: + AuthDelegationProvider.PersonalRepresentativeRegistry, + name: `Personal Representative: prRightType`, + description: `Personal representative delegation type for right type prRightType`, + }) + + const prRightType = await prRightTypeModel.create({ + code: 'prRightType', + description: 'prRightTypeDescription', + }) + + const prr = await prRightsModel.create({ + rightTypeCode: prRightType.code, + personalRepresentativeId: pr.id, + }) + + await prDelegationTypeModel.create({ + id: prr.id, + delegationTypeId: dt.id, + personalRepresentativeId: pr.id, + }) + }) - it('should not return delegations when client does not support personal representative delegations', async () => { - // Prepare - await clientDelegationTypeModel.destroy({ - where: { - clientId: client.clientId, - delegationType: AuthDelegationType.PersonalRepresentative, - }, - }) + describe('when fetched', () => { + let response: request.Response + let body: MergedDelegationDTO[] + + beforeAll(async () => { + response = await server.get(`${path}${query}`) + body = response.body + }) + + it('should have a an OK return status', () => { + expect(response.status).toEqual(200) + }) + + it('should return a single entity', () => { + expect(body.length).toEqual(1) + }) + + it('should have the nationalId of the user as the representer', () => { + expect( + body.some((d) => d.toNationalId === user.nationalId), + ).toBeTruthy() + }) + + it('should have the nationalId of the correct representee', () => { + expect( + body.some( + (d) => d.fromNationalId === nationalRegistryUser.nationalId, + ), + ).toBeTruthy() + }) + + it('should have the name of the correct representee', () => { + expect( + body.some((d) => d.fromName === nationalRegistryUser.name), + ).toBeTruthy() + }) + + it('should have the delegation type claim of PersonalRepresentative', () => { + expect( + body.some( + (d) => + d.types[0] === AuthDelegationType.PersonalRepresentative, + ), + ).toBeTruthy() + }) + }) - // Act - const res = await server.get(`${path}${query}`) + it('should not return delegations when client does not support personal representative delegations', async () => { + // Prepare + await clientDelegationTypeModel.destroy({ + where: { + clientId: client.clientId, + delegationType: AuthDelegationType.PersonalRepresentative, + }, + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + // Act + const res = await server.get(`${path}${query}`) - it('should return a personal representative delegation since the type is included in the delegationTypes filter', async () => { - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.ProcurationHolder}&delegationTypes=${AuthDelegationType.PersonalRepresentative}`, - ) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(1) - expect(res.body[0].types[0]).toEqual( - AuthDelegationType.PersonalRepresentative, - ) - }) + it('should return a personal representative delegation since the type is included in the delegationTypes filter', async () => { + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.ProcurationHolder}&delegationTypes=${AuthDelegationType.PersonalRepresentative}`, + ) + + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(1) + expect(res.body[0].types[0]).toEqual( + AuthDelegationType.PersonalRepresentative, + ) + }) - it('should not return a personal representative delegation since the type is not included in the delegationTypes filter', async () => { - // Act - const res = await server.get( - `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.LegalGuardian}&delegationTypes=${AuthDelegationType.ProcurationHolder}`, - ) + it('should not return a personal representative delegation since the type is not included in the delegationTypes filter', async () => { + // Act + const res = await server.get( + `${path}${query}&delegationTypes=${AuthDelegationType.Custom}&delegationTypes=${AuthDelegationType.LegalGuardian}&delegationTypes=${AuthDelegationType.ProcurationHolder}`, + ) - // Assert - expect(res.status).toEqual(200) - expect(res.body).toHaveLength(0) - }) + // Assert + expect(res.status).toEqual(200) + expect(res.body).toHaveLength(0) + }) - afterAll(async () => { - await prRightsModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await prRightTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await prModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await prTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await prDelegationTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await delegationTypeModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, - }) - await delegationProviderModel.destroy({ - where: {}, - cascade: true, - truncate: true, - force: true, + afterAll(async () => { + await prRightsModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prRightTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await prDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationProviderModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + }) }) }) }) - }) - }) + }, + ) describe('without auth and permission', () => { it.each` diff --git a/apps/web/layouts/organization/standalone/components/AlertBanner.tsx b/apps/web/layouts/organization/standalone/components/AlertBanner.tsx new file mode 100644 index 000000000000..32d09cec0150 --- /dev/null +++ b/apps/web/layouts/organization/standalone/components/AlertBanner.tsx @@ -0,0 +1,56 @@ +import Cookies from 'js-cookie' + +import { AlertBanner, AlertBannerVariants } from '@island.is/island-ui/core' +import { stringHash } from '@island.is/shared/utils' +import { OrganizationPage } from '@island.is/web/graphql/schema' +import { linkResolver, LinkType } from '@island.is/web/hooks' +import { useI18n } from '@island.is/web/i18n' + +interface StandaloneAlertBannerProps { + alertBanner: OrganizationPage['alertBanner'] | null | undefined +} + +export const StandaloneAlertBanner = ({ + alertBanner, +}: StandaloneAlertBannerProps) => { + const { activeLocale } = useI18n() + + if (!alertBanner) { + return null + } + + const bannerId = `standalone-alert-${stringHash( + JSON.stringify(alertBanner ?? {}), + )}` + + if (Cookies.get(bannerId) || !alertBanner.showAlertBanner) { + return null + } + + return ( + { + if (alertBanner.dismissedForDays !== 0) { + Cookies.set(bannerId, 'hide', { + expires: alertBanner.dismissedForDays, + }) + } + }} + closeButtonLabel={activeLocale === 'is' ? 'Loka' : 'Close'} + link={ + alertBanner.linkTitle && alertBanner.link?.type && alertBanner.link.slug + ? { + href: linkResolver(alertBanner.link.type as LinkType, [ + alertBanner.link.slug, + ]).href, + title: alertBanner.linkTitle, + } + : undefined + } + /> + ) +} diff --git a/apps/web/layouts/organization/standalone/standalone.tsx b/apps/web/layouts/organization/standalone/standalone.tsx index 9007c3db63a1..5053000cbff6 100644 --- a/apps/web/layouts/organization/standalone/standalone.tsx +++ b/apps/web/layouts/organization/standalone/standalone.tsx @@ -15,6 +15,7 @@ import { useLinkResolver } from '@island.is/web/hooks' import { useI18n } from '@island.is/web/i18n' import { getBackgroundStyle } from '@island.is/web/utils/organization' +import { StandaloneAlertBanner } from './components/AlertBanner' import { Header, HeaderProps } from './components/Header' import { Navigation, NavigationProps } from './components/Navigation' @@ -204,6 +205,7 @@ export const StandaloneLayout = ({ } /> +
diff --git a/apps/web/pages/s/[...slugs]/index.tsx b/apps/web/pages/s/[...slugs]/index.tsx index 8c2cbde982af..5632dbe156ba 100644 --- a/apps/web/pages/s/[...slugs]/index.tsx +++ b/apps/web/pages/s/[...slugs]/index.tsx @@ -31,6 +31,9 @@ import PublishedMaterial, { import StandaloneHome, { type StandaloneHomeProps, } from '@island.is/web/screens/Organization/Standalone/Home' +import StandaloneParentSubpage, { + StandaloneParentSubpageProps, +} from '@island.is/web/screens/Organization/Standalone/ParentSubpage' import SubPage, { type SubPageProps, } from '@island.is/web/screens/Organization/SubPage' @@ -42,6 +45,7 @@ import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePro enum PageType { FRONTPAGE = 'frontpage', STANDALONE_FRONTPAGE = 'standalone-frontpage', + STANDALONE_PARENT_SUBPAGE = 'standalone-parent-subpage', SUBPAGE = 'subpage', ALL_NEWS = 'news', PUBLISHED_MATERIAL = 'published-material', @@ -55,6 +59,9 @@ enum PageType { const pageMap: Record> = { [PageType.FRONTPAGE]: (props) => , [PageType.STANDALONE_FRONTPAGE]: (props) => , + [PageType.STANDALONE_PARENT_SUBPAGE]: (props) => ( + + ), [PageType.SUBPAGE]: (props) => , [PageType.ALL_NEWS]: (props) => , [PageType.PUBLISHED_MATERIAL]: (props) => , @@ -79,6 +86,10 @@ interface Props { type: PageType.STANDALONE_FRONTPAGE props: StandaloneHomeProps } + | { + type: PageType.STANDALONE_PARENT_SUBPAGE + props: StandaloneParentSubpageProps + } | { type: PageType.SUBPAGE props: { @@ -135,32 +146,32 @@ Component.getProps = async (context) => { const slugs = context.query.slugs as string[] const locale = context.locale || 'is' - // Frontpage - if (slugs.length === 1) { - const { - data: { getOrganizationPage: organizationPage }, - } = await context.apolloClient.query({ - query: GET_ORGANIZATION_PAGE_QUERY, - variables: { - input: { - slug: slugs[0], - lang: locale, - }, + const { + data: { getOrganizationPage: organizationPage }, + } = await context.apolloClient.query({ + query: GET_ORGANIZATION_PAGE_QUERY, + variables: { + input: { + slug: slugs[0], + lang: locale, }, - }) + }, + }) - if (!organizationPage) { - throw new CustomNextError(404) - } + if (!organizationPage) { + throw new CustomNextError(404, 'Organization page was not found') + } + + const modifiedContext = { ...context, organizationPage } + + const STANDALONE_THEME = 'standalone' - if (organizationPage.theme === 'standalone') { + if (slugs.length === 1) { + if (organizationPage.theme === STANDALONE_THEME) { return { page: { type: PageType.STANDALONE_FRONTPAGE, - props: await StandaloneHome.getProps({ - ...context, - organizationPage, - }), + props: await StandaloneHome.getProps(modifiedContext), }, } } @@ -168,7 +179,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.FRONTPAGE, - props: await Home.getProps({ ...context, organizationPage }), + props: await Home.getProps(modifiedContext), }, } } @@ -179,7 +190,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.ALL_NEWS, - props: await OrganizationNewsList.getProps(context), + props: await OrganizationNewsList.getProps(modifiedContext), }, } } @@ -187,7 +198,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.ALL_EVENTS, - props: await OrganizationEventList.getProps(context), + props: await OrganizationEventList.getProps(modifiedContext), }, } } @@ -195,7 +206,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.PUBLISHED_MATERIAL, - props: await PublishedMaterial.getProps(context), + props: await PublishedMaterial.getProps(modifiedContext), }, } } @@ -204,7 +215,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.ALL_NEWS, - props: await OrganizationNewsList.getProps(context), + props: await OrganizationNewsList.getProps(modifiedContext), }, } } @@ -212,7 +223,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.ALL_EVENTS, - props: await OrganizationEventList.getProps(context), + props: await OrganizationEventList.getProps(modifiedContext), }, } } @@ -220,18 +231,25 @@ Component.getProps = async (context) => { return { page: { type: PageType.PUBLISHED_MATERIAL, - props: await PublishedMaterial.getProps(context), + props: await PublishedMaterial.getProps(modifiedContext), }, } } } - // Subpage - const props = await SubPage.getProps(context) + if (organizationPage.theme === STANDALONE_THEME) { + return { + page: { + type: PageType.STANDALONE_PARENT_SUBPAGE, + props: await StandaloneParentSubpage.getProps(modifiedContext), + }, + } + } + return { page: { type: PageType.SUBPAGE, - props, + props: await SubPage.getProps(modifiedContext), }, } } @@ -242,7 +260,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.NEWS_DETAILS, - props: await OrganizationNewsArticle.getProps(context), + props: await OrganizationNewsArticle.getProps(modifiedContext), }, } } @@ -250,7 +268,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.EVENT_DETAILS, - props: await OrganizationEventArticle.getProps(context), + props: await OrganizationEventArticle.getProps(modifiedContext), }, } } @@ -259,7 +277,7 @@ Component.getProps = async (context) => { return { page: { type: PageType.NEWS_DETAILS, - props: await OrganizationNewsArticle.getProps(context), + props: await OrganizationNewsArticle.getProps(modifiedContext), }, } } @@ -267,16 +285,27 @@ Component.getProps = async (context) => { return { page: { type: PageType.EVENT_DETAILS, - props: await OrganizationEventArticle.getProps(context), + props: await OrganizationEventArticle.getProps(modifiedContext), }, } } } + if (organizationPage.theme === STANDALONE_THEME) { + return { + page: { + type: PageType.STANDALONE_PARENT_SUBPAGE, + props: await StandaloneParentSubpage.getProps(modifiedContext), + }, + } + } + return { page: { type: PageType.GENERIC_LIST_ITEM, - props: await OrganizationSubPageGenericListItem.getProps(context), + props: await OrganizationSubPageGenericListItem.getProps( + modifiedContext, + ), }, } } diff --git a/apps/web/screens/GenericList/OrganizationSubPageGenericListItem.tsx b/apps/web/screens/GenericList/OrganizationSubPageGenericListItem.tsx index 7bba1474eef6..062fca30e94b 100644 --- a/apps/web/screens/GenericList/OrganizationSubPageGenericListItem.tsx +++ b/apps/web/screens/GenericList/OrganizationSubPageGenericListItem.tsx @@ -1,10 +1,11 @@ import { useMemo } from 'react' import { useRouter } from 'next/router' +import { Query } from '@island.is/web/graphql/schema' import { useLinkResolver } from '@island.is/web/hooks' import { useI18n } from '@island.is/web/i18n' -import { type LayoutProps, withMainLayout } from '@island.is/web/layouts/main' -import { Screen } from '@island.is/web/types' +import { type LayoutProps } from '@island.is/web/layouts/main' +import { Screen, ScreenContext } from '@island.is/web/types' import SubPage, { type SubPageProps } from '../Organization/SubPage' import GenericListItemPage, { @@ -19,8 +20,13 @@ export interface OrganizationSubPageGenericListItemProps { genericListItemProps: GenericListItemPageProps } +type OrganizationSubPageGenericListItemScreenContext = ScreenContext & { + organizationPage?: Query['getOrganizationPage'] +} + const OrganizationSubPageGenericListItem: Screen< - OrganizationSubPageGenericListItemProps + OrganizationSubPageGenericListItemProps, + OrganizationSubPageGenericListItemScreenContext > = (props) => { const { organizationPage, subpage } = props.parentProps.componentProps const router = useRouter() diff --git a/apps/web/screens/Organization/OrganizationEvents/OrganizationEventArticle.tsx b/apps/web/screens/Organization/OrganizationEvents/OrganizationEventArticle.tsx index 760d40330a9e..c8295301cdfa 100644 --- a/apps/web/screens/Organization/OrganizationEvents/OrganizationEventArticle.tsx +++ b/apps/web/screens/Organization/OrganizationEvents/OrganizationEventArticle.tsx @@ -41,7 +41,7 @@ import { useWindowSize } from '@island.is/web/hooks/useViewport' import { useI18n } from '@island.is/web/i18n' import { useDateUtils } from '@island.is/web/i18n/useDateUtils' import { withMainLayout } from '@island.is/web/layouts/main' -import type { Screen } from '@island.is/web/types' +import type { Screen, ScreenContext } from '@island.is/web/types' import { CustomNextError } from '@island.is/web/units/errors' import { extractNamespaceFromOrganization } from '@island.is/web/utils/extractNamespaceFromOrganization' import { getOrganizationSidebarNavigationItems } from '@island.is/web/utils/organization' @@ -141,12 +141,14 @@ export interface OrganizationEventArticleProps { locale: Locale } -const OrganizationEventArticle: Screen = ({ - organizationPage, - event, - namespace, - locale, -}) => { +type OrganizationEventArticleScreenContext = ScreenContext & { + organizationPage?: Query['getOrganizationPage'] +} + +const OrganizationEventArticle: Screen< + OrganizationEventArticleProps, + OrganizationEventArticleScreenContext +> = ({ organizationPage, event, namespace, locale }) => { const n = useNamespace(namespace) const router = useRouter() @@ -285,19 +287,26 @@ const OrganizationEventArticle: Screen = ({ ) } -OrganizationEventArticle.getProps = async ({ apolloClient, query, locale }) => { +OrganizationEventArticle.getProps = async ({ + apolloClient, + query, + locale, + organizationPage: _organizationPage, +}) => { const [organizationPageSlug, _, eventSlug] = query.slugs as string[] const [organizationPageResponse, eventResponse, namespace] = await Promise.all([ - apolloClient.query({ - query: GET_ORGANIZATION_PAGE_QUERY, - variables: { - input: { - slug: organizationPageSlug, - lang: locale as Locale, - }, - }, - }), + !_organizationPage + ? apolloClient.query({ + query: GET_ORGANIZATION_PAGE_QUERY, + variables: { + input: { + slug: organizationPageSlug, + lang: locale as Locale, + }, + }, + }) + : { data: { getOrganizationPage: _organizationPage } }, apolloClient.query({ query: GET_SINGLE_EVENT_QUERY, variables: { diff --git a/apps/web/screens/Organization/OrganizationNews/OrganizationNewsArticle.tsx b/apps/web/screens/Organization/OrganizationNews/OrganizationNewsArticle.tsx index d5da623eb539..24bf3f060b60 100644 --- a/apps/web/screens/Organization/OrganizationNews/OrganizationNewsArticle.tsx +++ b/apps/web/screens/Organization/OrganizationNews/OrganizationNewsArticle.tsx @@ -28,7 +28,7 @@ import { GET_ORGANIZATION_PAGE_QUERY, GET_SINGLE_NEWS_ITEM_QUERY, } from '@island.is/web/screens/queries' -import { Screen } from '@island.is/web/types' +import type { Screen, ScreenContext } from '@island.is/web/types' import { CustomNextError } from '@island.is/web/units/errors' import { extractNamespaceFromOrganization } from '@island.is/web/utils/extractNamespaceFromOrganization' @@ -39,12 +39,14 @@ export interface OrganizationNewsArticleProps { locale: Locale } -const OrganizationNewsArticle: Screen = ({ - newsItem, - namespace, - organizationPage, - locale, -}) => { +type OrganizationNewsArticleScreenContext = ScreenContext & { + organizationPage?: Query['getOrganizationPage'] +} + +const OrganizationNewsArticle: Screen< + OrganizationNewsArticleProps, + OrganizationNewsArticleScreenContext +> = ({ newsItem, namespace, organizationPage, locale }) => { const router = useRouter() const { linkResolver } = useLinkResolver() // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -163,22 +165,27 @@ const OrganizationNewsArticle: Screen = ({ ) } -OrganizationNewsArticle.getProps = async ({ apolloClient, locale, query }) => { +OrganizationNewsArticle.getProps = async ({ + apolloClient, + locale, + query, + organizationPage: _organizationPage, +}) => { const [organizationPageSlug, _, newsSlug] = query.slugs as string[] - const organizationPage = ( - await Promise.resolve( - apolloClient.query({ - query: GET_ORGANIZATION_PAGE_QUERY, - variables: { - input: { - slug: organizationPageSlug, - lang: locale as Locale, + const organizationPage = !_organizationPage + ? ( + await apolloClient.query({ + query: GET_ORGANIZATION_PAGE_QUERY, + variables: { + input: { + slug: organizationPageSlug, + lang: locale as Locale, + }, }, - }, - }), - ) - ).data?.getOrganizationPage + }) + ).data?.getOrganizationPage + : _organizationPage if (!organizationPage) { throw new CustomNextError( diff --git a/apps/web/screens/Organization/Standalone/ParentSubpage.tsx b/apps/web/screens/Organization/Standalone/ParentSubpage.tsx new file mode 100644 index 000000000000..ad4e08f629c9 --- /dev/null +++ b/apps/web/screens/Organization/Standalone/ParentSubpage.tsx @@ -0,0 +1,251 @@ +import { useRouter } from 'next/router' + +import { + Breadcrumbs, + GridColumn, + GridContainer, + GridRow, + Stack, + TableOfContents, + Text, +} from '@island.is/island-ui/core' +import { + ContentLanguage, + OrganizationPage, + OrganizationParentSubpage, + OrganizationSubpage, + Query, + QueryGetNamespaceArgs, + QueryGetOrganizationPageArgs, + QueryGetOrganizationParentSubpageArgs, + QueryGetOrganizationSubpageArgs, +} from '@island.is/web/graphql/schema' +import { useLinkResolver } from '@island.is/web/hooks' +import { useI18n } from '@island.is/web/i18n' +import { StandaloneLayout } from '@island.is/web/layouts/organization/standalone' +import type { Screen, ScreenContext } from '@island.is/web/types' +import { CustomNextError } from '@island.is/web/units/errors' + +import { + GET_NAMESPACE_QUERY, + GET_ORGANIZATION_PAGE_QUERY, + GET_ORGANIZATION_PARENT_SUBPAGE_QUERY, + GET_ORGANIZATION_SUBPAGE_QUERY, +} from '../../queries' +import { SubPageContent } from '../SubPage' + +type StandaloneParentSubpageScreenContext = ScreenContext & { + organizationPage?: Query['getOrganizationPage'] +} + +export interface StandaloneParentSubpageProps { + organizationPage: OrganizationPage + subpage: OrganizationSubpage + tableOfContentHeadings: { + headingId: string + headingTitle: string + label: string + href: string + }[] + selectedHeadingId: string + parentSubpage: OrganizationParentSubpage + namespace: Record +} + +const StandaloneParentSubpage: Screen< + StandaloneParentSubpageProps, + StandaloneParentSubpageScreenContext +> = ({ + organizationPage, + parentSubpage, + selectedHeadingId, + subpage, + tableOfContentHeadings, + namespace, +}) => { + const router = useRouter() + const { activeLocale } = useI18n() + const { linkResolver } = useLinkResolver() + + return ( + + + + + + + {parentSubpage.childLinks.length > 1 && ( + + + {parentSubpage.title} + + { + const href = tableOfContentHeadings.find( + (heading) => heading.headingId === headingId, + )?.href + if (href) { + router.push(href) + } + }} + tableOfContentsTitle={ + namespace?.['standaloneTableOfContentsTitle'] ?? + activeLocale === 'is' + ? 'Efnisyfirlit' + : 'Table of contents' + } + selectedHeadingId={selectedHeadingId} + /> + + )} + + + + 1 ? 'h2' : 'h1' + } + /> + + + ) +} + +StandaloneParentSubpage.getProps = async ({ + apolloClient, + locale, + query, + organizationPage, +}) => { + const [organizationPageSlug, parentSubpageSlug, subpageSlug] = (query.slugs ?? + []) as string[] + + const [ + { + data: { getOrganizationPage }, + }, + { + data: { getOrganizationParentSubpage }, + }, + namespace, + ] = await Promise.all([ + !organizationPage + ? apolloClient.query({ + query: GET_ORGANIZATION_PAGE_QUERY, + variables: { + input: { + slug: organizationPageSlug, + lang: locale as ContentLanguage, + }, + }, + }) + : { + data: { getOrganizationPage: organizationPage }, + }, + apolloClient.query({ + query: GET_ORGANIZATION_PARENT_SUBPAGE_QUERY, + variables: { + input: { + organizationPageSlug: organizationPageSlug as string, + slug: parentSubpageSlug as string, + lang: locale as ContentLanguage, + }, + }, + }), + apolloClient + .query({ + query: GET_NAMESPACE_QUERY, + variables: { + input: { + namespace: 'OrganizationPages', + lang: locale, + }, + }, + }) + .then((variables) => + variables.data.getNamespace?.fields + ? JSON.parse(variables.data.getNamespace.fields) + : {}, + ), + ]) + + if (!getOrganizationPage) { + throw new CustomNextError(404, 'Organization page not found') + } + + if (!getOrganizationParentSubpage) { + throw new CustomNextError(404, 'Organization parent subpage was not found') + } + + let selectedIndex = 0 + + if (subpageSlug) { + const index = getOrganizationParentSubpage.childLinks.findIndex( + (link) => link.href.split('/').pop() === subpageSlug, + ) + if (index >= 0) { + selectedIndex = index + } else { + throw new CustomNextError( + 404, + 'Subpage belonging to an organization parent subpage was not found', + ) + } + } + + const subpage = ( + await apolloClient.query({ + query: GET_ORGANIZATION_SUBPAGE_QUERY, + variables: { + input: { + organizationSlug: organizationPageSlug, + slug: getOrganizationParentSubpage.childLinks[selectedIndex].href + .split('/') + .pop() as string, + lang: locale as ContentLanguage, + }, + }, + }) + ).data.getOrganizationSubpage + + if (!subpage) { + throw new CustomNextError( + 404, + 'Subpage belonging to an organization parent subpage was not found', + ) + } + + const tableOfContentHeadings = getOrganizationParentSubpage.childLinks.map( + (link) => ({ + headingId: link.href, + headingTitle: link.label, + label: link.label, + href: link.href, + }), + ) + const selectedHeadingId = tableOfContentHeadings[selectedIndex].headingId + + return { + organizationPage: getOrganizationPage, + parentSubpage: getOrganizationParentSubpage, + subpage, + tableOfContentHeadings, + selectedHeadingId, + namespace, + } +} + +export default StandaloneParentSubpage diff --git a/apps/web/screens/Organization/SubPage.tsx b/apps/web/screens/Organization/SubPage.tsx index 6d1dc7697cb3..6d81780073b2 100644 --- a/apps/web/screens/Organization/SubPage.tsx +++ b/apps/web/screens/Organization/SubPage.tsx @@ -43,7 +43,7 @@ import { extractNamespaceFromOrganization } from '@island.is/web/utils/extractNa import { webRichText } from '@island.is/web/utils/richText' import { safelyExtractPathnameFromUrl } from '@island.is/web/utils/safelyExtractPathnameFromUrl' -import { Screen } from '../../types' +import { Screen, ScreenContext } from '../../types' import { GET_NAMESPACE_QUERY, GET_ORGANIZATION_PAGE_QUERY, @@ -61,11 +61,14 @@ export interface SubPageProps { customContentfulIds?: (string | undefined)[] } -const SubPageContent = ({ +export const SubPageContent = ({ subpage, namespace, organizationPage, -}: Pick) => { + subpageTitleVariant = 'h1', +}: Pick & { + subpageTitleVariant?: 'h1' | 'h2' +}) => { const n = useNamespace(namespace) const { activeLocale } = useI18n() const content = ( @@ -132,7 +135,10 @@ const SubPageContent = ({ > <> - + {subpage?.title} @@ -208,7 +214,11 @@ const SubPageContent = ({ ) } -const SubPage: Screen = ({ +type SubPageScreenContext = ScreenContext & { + organizationPage?: Query['getOrganizationPage'] +} + +const SubPage: Screen = ({ organizationPage, subpage, namespace, @@ -357,7 +367,13 @@ const renderSlices = ( } } -SubPage.getProps = async ({ apolloClient, locale, query, req }) => { +SubPage.getProps = async ({ + apolloClient, + locale, + query, + req, + organizationPage, +}) => { const pathname = safelyExtractPathnameFromUrl(req.url) const { slug, subSlug } = getSlugAndSubSlug(query, pathname) @@ -370,15 +386,19 @@ SubPage.getProps = async ({ apolloClient, locale, query, req }) => { }, namespace, ] = await Promise.all([ - apolloClient.query({ - query: GET_ORGANIZATION_PAGE_QUERY, - variables: { - input: { - slug: slug as string, - lang: locale as ContentLanguage, + !organizationPage + ? apolloClient.query({ + query: GET_ORGANIZATION_PAGE_QUERY, + variables: { + input: { + slug: slug as string, + lang: locale as ContentLanguage, + }, + }, + }) + : { + data: { getOrganizationPage: organizationPage }, }, - }, - }), apolloClient.query({ query: GET_ORGANIZATION_SUBPAGE_QUERY, variables: { diff --git a/apps/web/screens/queries/Organization.tsx b/apps/web/screens/queries/Organization.tsx index f5a6cd3c5e92..fea842378a77 100644 --- a/apps/web/screens/queries/Organization.tsx +++ b/apps/web/screens/queries/Organization.tsx @@ -416,3 +416,17 @@ export const EMAIL_SIGNUP_MUTATION = gql` } } ` + +export const GET_ORGANIZATION_PARENT_SUBPAGE_QUERY = gql` + query GetOrganizationParentSubpageQuery( + $input: GetOrganizationParentSubpageInput! + ) { + getOrganizationParentSubpage(input: $input) { + title + childLinks { + label + href + } + } + } +` diff --git a/charts/identity-server/values.dev.yaml b/charts/identity-server/values.dev.yaml index e409d5e63565..37227fdaaedd 100644 --- a/charts/identity-server/values.dev.yaml +++ b/charts/identity-server/values.dev.yaml @@ -227,6 +227,10 @@ services-auth-admin-api: IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' IDENTITY_SERVER_ISSUER_URL_LIST: '["https://identity-server.dev01.devland.is","https://identity-server.staging01.devland.is","https://innskra.island.is"]' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-DEV/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=691 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -297,6 +301,7 @@ services-auth-admin-api: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' @@ -605,6 +610,10 @@ services-auth-personal-representative: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-DEV/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -670,6 +679,7 @@ services-auth-personal-representative: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' @@ -752,6 +762,10 @@ services-auth-public-api: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-DEV/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=345 -r dd-trace/init' PASSKEY_CORE_ALLOWED_ORIGINS: '["https://island.is","android:apk-key-hash:JgPeo_F6KYk-ngRa26tO2SsAtMiTBQCc7WtSgN-jRX0","android:apk-key-hash:EsLTUu5kaY7XPmMl2f7nbq4amu-PNzdYu3FecNf90wU"]' PASSKEY_CORE_CHALLENGE_TTL_MS: '120000' @@ -832,6 +846,7 @@ services-auth-public-api: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/identity-server/values.prod.yaml b/charts/identity-server/values.prod.yaml index 7a6fc66e89f3..1ceb0720407e 100644 --- a/charts/identity-server/values.prod.yaml +++ b/charts/identity-server/values.prod.yaml @@ -224,6 +224,10 @@ services-auth-admin-api: IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' IDENTITY_SERVER_ISSUER_URL_LIST: '["https://innskra.island.is"]' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '8271bbc2-d8de-480f-8540-ea43fc40b7ae' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentity.b2clogin.com/skraidentity.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS/GOV/6503760649/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentity.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=691 -r dd-trace/init' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' SYSLUMENN_HOST: 'https://api.syslumenn.is/api' @@ -294,6 +298,7 @@ services-auth-admin-api: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' @@ -602,6 +607,10 @@ services-auth-personal-representative: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '8271bbc2-d8de-480f-8540-ea43fc40b7ae' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentity.b2clogin.com/skraidentity.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS/GOV/6503760649/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentity.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' SYSLUMENN_HOST: 'https://api.syslumenn.is/api' @@ -667,6 +676,7 @@ services-auth-personal-representative: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' @@ -749,6 +759,10 @@ services-auth-public-api: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '8271bbc2-d8de-480f-8540-ea43fc40b7ae' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentity.b2clogin.com/skraidentity.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS/GOV/6503760649/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentity.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=345 -r dd-trace/init' PASSKEY_CORE_ALLOWED_ORIGINS: '["https://island.is","android:apk-key-hash:JgPeo_F6KYk-ngRa26tO2SsAtMiTBQCc7WtSgN-jRX0","android:apk-key-hash:EsLTUu5kaY7XPmMl2f7nbq4amu-PNzdYu3FecNf90wU"]' PASSKEY_CORE_CHALLENGE_TTL_MS: '120000' @@ -829,6 +843,7 @@ services-auth-public-api: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/identity-server/values.staging.yaml b/charts/identity-server/values.staging.yaml index 57c2727f4daa..a08cbe1f20a0 100644 --- a/charts/identity-server/values.staging.yaml +++ b/charts/identity-server/values.staging.yaml @@ -227,6 +227,10 @@ services-auth-admin-api: IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' IDENTITY_SERVER_ISSUER_URL_LIST: '["https://identity-server.staging01.devland.is","https://innskra.island.is"]' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-TEST/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=691 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -297,6 +301,7 @@ services-auth-admin-api: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' @@ -605,6 +610,10 @@ services-auth-personal-representative: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-TEST/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -670,6 +679,7 @@ services-auth-personal-representative: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' @@ -752,6 +762,10 @@ services-auth-public-api: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-TEST/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=345 -r dd-trace/init' PASSKEY_CORE_ALLOWED_ORIGINS: '["https://island.is","android:apk-key-hash:JgPeo_F6KYk-ngRa26tO2SsAtMiTBQCc7WtSgN-jRX0","android:apk-key-hash:EsLTUu5kaY7XPmMl2f7nbq4amu-PNzdYu3FecNf90wU"]' PASSKEY_CORE_CHALLENGE_TTL_MS: '120000' @@ -832,6 +846,7 @@ services-auth-public-api: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/services/services-auth-admin-api/values.dev.yaml b/charts/services/services-auth-admin-api/values.dev.yaml index f2b108d4a7ce..a792c06696f7 100644 --- a/charts/services/services-auth-admin-api/values.dev.yaml +++ b/charts/services/services-auth-admin-api/values.dev.yaml @@ -29,6 +29,10 @@ env: IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' IDENTITY_SERVER_ISSUER_URL_LIST: '["https://identity-server.dev01.devland.is","https://identity-server.staging01.devland.is","https://innskra.island.is"]' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-DEV/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=691 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -99,6 +103,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/services/services-auth-admin-api/values.prod.yaml b/charts/services/services-auth-admin-api/values.prod.yaml index f392a1400099..cc6563a905ed 100644 --- a/charts/services/services-auth-admin-api/values.prod.yaml +++ b/charts/services/services-auth-admin-api/values.prod.yaml @@ -29,6 +29,10 @@ env: IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' IDENTITY_SERVER_ISSUER_URL_LIST: '["https://innskra.island.is"]' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '8271bbc2-d8de-480f-8540-ea43fc40b7ae' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentity.b2clogin.com/skraidentity.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS/GOV/6503760649/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentity.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=691 -r dd-trace/init' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' SYSLUMENN_HOST: 'https://api.syslumenn.is/api' @@ -99,6 +103,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/services/services-auth-admin-api/values.staging.yaml b/charts/services/services-auth-admin-api/values.staging.yaml index 1ca982a2abb7..e67c4abfceac 100644 --- a/charts/services/services-auth-admin-api/values.staging.yaml +++ b/charts/services/services-auth-admin-api/values.staging.yaml @@ -29,6 +29,10 @@ env: IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' IDENTITY_SERVER_ISSUER_URL_LIST: '["https://identity-server.staging01.devland.is","https://innskra.island.is"]' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-TEST/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=691 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -99,6 +103,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/services/services-auth-personal-representative/values.dev.yaml b/charts/services/services-auth-personal-representative/values.dev.yaml index 973eb041bc32..aaca50f4c0c0 100644 --- a/charts/services/services-auth-personal-representative/values.dev.yaml +++ b/charts/services/services-auth-personal-representative/values.dev.yaml @@ -28,6 +28,10 @@ env: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-DEV/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -93,6 +97,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' diff --git a/charts/services/services-auth-personal-representative/values.prod.yaml b/charts/services/services-auth-personal-representative/values.prod.yaml index f9f74f8d92c1..e65c0aafe6fa 100644 --- a/charts/services/services-auth-personal-representative/values.prod.yaml +++ b/charts/services/services-auth-personal-representative/values.prod.yaml @@ -28,6 +28,10 @@ env: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '8271bbc2-d8de-480f-8540-ea43fc40b7ae' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentity.b2clogin.com/skraidentity.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS/GOV/6503760649/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentity.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' SYSLUMENN_HOST: 'https://api.syslumenn.is/api' @@ -93,6 +97,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' diff --git a/charts/services/services-auth-personal-representative/values.staging.yaml b/charts/services/services-auth-personal-representative/values.staging.yaml index 21ec99d0547a..16fadeb06ab7 100644 --- a/charts/services/services-auth-personal-representative/values.staging.yaml +++ b/charts/services/services-auth-personal-representative/values.staging.yaml @@ -28,6 +28,10 @@ env: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-TEST/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' SERVERSIDE_FEATURES_ON: '' SYSLUMENN_HOST: 'https://api.syslumenn.is/staging' @@ -93,6 +97,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' diff --git a/charts/services/services-auth-public-api/values.dev.yaml b/charts/services/services-auth-public-api/values.dev.yaml index 4398430d7eb2..9b4cc091ee46 100644 --- a/charts/services/services-auth-public-api/values.dev.yaml +++ b/charts/services/services-auth-public-api/values.dev.yaml @@ -28,6 +28,10 @@ env: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-DEV/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=345 -r dd-trace/init' PASSKEY_CORE_ALLOWED_ORIGINS: '["https://island.is","android:apk-key-hash:JgPeo_F6KYk-ngRa26tO2SsAtMiTBQCc7WtSgN-jRX0","android:apk-key-hash:EsLTUu5kaY7XPmMl2f7nbq4amu-PNzdYu3FecNf90wU"]' PASSKEY_CORE_CHALLENGE_TTL_MS: '120000' @@ -108,6 +112,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/services/services-auth-public-api/values.prod.yaml b/charts/services/services-auth-public-api/values.prod.yaml index cedab910898a..13e897e62429 100644 --- a/charts/services/services-auth-public-api/values.prod.yaml +++ b/charts/services/services-auth-public-api/values.prod.yaml @@ -28,6 +28,10 @@ env: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '8271bbc2-d8de-480f-8540-ea43fc40b7ae' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentity.b2clogin.com/skraidentity.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS/GOV/6503760649/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentity.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=345 -r dd-trace/init' PASSKEY_CORE_ALLOWED_ORIGINS: '["https://island.is","android:apk-key-hash:JgPeo_F6KYk-ngRa26tO2SsAtMiTBQCc7WtSgN-jRX0","android:apk-key-hash:EsLTUu5kaY7XPmMl2f7nbq4amu-PNzdYu3FecNf90wU"]' PASSKEY_CORE_CHALLENGE_TTL_MS: '120000' @@ -108,6 +112,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/charts/services/services-auth-public-api/values.staging.yaml b/charts/services/services-auth-public-api/values.staging.yaml index af05b8333772..ba19f2a7a3bf 100644 --- a/charts/services/services-auth-public-api/values.staging.yaml +++ b/charts/services/services-auth-public-api/values.staging.yaml @@ -28,6 +28,10 @@ env: IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' LOG_LEVEL: 'info' + NATIONAL_REGISTRY_B2C_CLIENT_ID: '6cf94113-d326-4e4d-b97c-1fea12d2f5e1' + NATIONAL_REGISTRY_B2C_ENDPOINT: 'https://skraidentitydev.b2clogin.com/skraidentitydev.onmicrosoft.com/b2c_1_midlun_flow/oauth2/v2.0/token' + NATIONAL_REGISTRY_B2C_PATH: 'IS-TEST/GOV/10001/SKRA-Cloud-Protected/Midlun-v1' + NATIONAL_REGISTRY_B2C_SCOPE: 'https://skraidentitydev.onmicrosoft.com/midlun/.default' NODE_OPTIONS: '--max-old-space-size=345 -r dd-trace/init' PASSKEY_CORE_ALLOWED_ORIGINS: '["https://island.is","android:apk-key-hash:JgPeo_F6KYk-ngRa26tO2SsAtMiTBQCc7WtSgN-jRX0","android:apk-key-hash:EsLTUu5kaY7XPmMl2f7nbq4amu-PNzdYu3FecNf90wU"]' PASSKEY_CORE_CHALLENGE_TTL_MS: '120000' @@ -108,6 +112,7 @@ secrets: CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' DB_PASS: '/k8s/servicesauth/DB_PASSWORD' IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET' + NATIONAL_REGISTRY_B2C_CLIENT_SECRET: '/k8s/services-auth/NATIONAL_REGISTRY_B2C_CLIENT_SECRET' NATIONAL_REGISTRY_IDS_CLIENT_SECRET: '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET' SYSLUMENN_PASSWORD: '/k8s/services-auth/SYSLUMENN_PASSWORD' SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' diff --git a/libs/application/core/src/lib/fieldBuilders.ts b/libs/application/core/src/lib/fieldBuilders.ts index 2f1a86f6a03c..2c77343d5aca 100644 --- a/libs/application/core/src/lib/fieldBuilders.ts +++ b/libs/application/core/src/lib/fieldBuilders.ts @@ -46,6 +46,8 @@ import { SliderField, MaybeWithApplication, MaybeWithApplicationAndFieldAndLocale, + DisplayField, + FieldsRepeaterField, } from '@island.is/application/types' import { Locale } from '@island.is/shared/types' import { Colors } from '@island.is/island-ui/theme' @@ -867,6 +869,48 @@ export const buildTableRepeaterField = ( } } +export const buildFieldsRepeaterField = ( + data: Omit, +): FieldsRepeaterField => { + const { + fields, + table, + title, + titleVariant, + formTitle, + formTitleVariant, + formTitleNumbering, + marginTop, + marginBottom, + removeItemButtonText, + addItemButtonText, + saveItemButtonText, + minRows, + maxRows, + } = data + + return { + ...extractCommonFields(data), + children: undefined, + type: FieldTypes.FIELDS_REPEATER, + component: FieldComponents.FIELDS_REPEATER, + fields, + table, + title, + titleVariant, + formTitle, + formTitleVariant, + formTitleNumbering, + marginTop, + marginBottom, + removeItemButtonText, + addItemButtonText, + saveItemButtonText, + minRows, + maxRows, + } +} + export const buildStaticTableField = ( data: Omit< StaticTableField, @@ -966,3 +1010,34 @@ export const buildSliderField = ( saveAsString, } } + +export const buildDisplayField = ( + data: Omit, +): DisplayField => { + const { + title, + titleVariant, + label, + variant, + marginTop, + marginBottom, + value, + suffix, + rightAlign, + } = data + return { + ...extractCommonFields(data), + title, + titleVariant, + label, + variant, + marginTop, + marginBottom, + type: FieldTypes.DISPLAY, + component: FieldComponents.DISPLAY, + children: undefined, + value, + suffix, + rightAlign, + } +} diff --git a/libs/application/core/src/lib/messages.ts b/libs/application/core/src/lib/messages.ts index 3190a3542b1a..65cb2f5cdd67 100644 --- a/libs/application/core/src/lib/messages.ts +++ b/libs/application/core/src/lib/messages.ts @@ -41,6 +41,11 @@ export const coreMessages = defineMessages({ defaultMessage: 'Bæta við', description: 'Add button', }, + buttonRemove: { + id: 'application.system:button.remove', + defaultMessage: 'Fjarlægja', + description: 'Remove button', + }, buttonCancel: { id: 'application.system:button.cancel', defaultMessage: 'Hætta við', diff --git a/libs/application/templates/inheritance-report/src/fields/HeirsRepeater/index.tsx b/libs/application/templates/inheritance-report/src/fields/HeirsRepeater/index.tsx index 639878598c91..768a0c81c0b4 100644 --- a/libs/application/templates/inheritance-report/src/fields/HeirsRepeater/index.tsx +++ b/libs/application/templates/inheritance-report/src/fields/HeirsRepeater/index.tsx @@ -213,9 +213,17 @@ export const HeirsRepeater: FC< : valueToNumber(getValueViaPath(answers, 'netPropertyForExchange')) const inheritanceValue = netPropertyForExchange * percentage + const customSpouseSharePercentage = getValueViaPath( + answers, + 'customShare.customSpouseSharePercentage', + ) + const withCustomPercentage = + (100 - Number(customSpouseSharePercentage)) * 2 const taxFreeInheritanceValue = isSpouse ? inheritanceValue + : customSpouseSharePercentage + ? (withCustomPercentage / 100) * inheritanceTaxFreeLimit * percentage : inheritanceTaxFreeLimit * percentage const taxableInheritanceValue = inheritanceValue - taxFreeInheritanceValue diff --git a/libs/application/templates/inheritance-report/src/forms/sections/applicationTypeSelection.ts b/libs/application/templates/inheritance-report/src/forms/sections/applicationTypeSelection.ts index 10ffd1e7f6fd..2bff9470a59a 100644 --- a/libs/application/templates/inheritance-report/src/forms/sections/applicationTypeSelection.ts +++ b/libs/application/templates/inheritance-report/src/forms/sections/applicationTypeSelection.ts @@ -31,7 +31,8 @@ export const preSelection = buildSection({ }), buildMultiField({ id: 'estate', - title: m.applicationName, + title: m.selectEstate, + description: m.selectEstateDescription, condition: (answers) => answers.applicationFor === ESTATE_INHERITANCE, children: [ buildSelectField({ diff --git a/libs/application/templates/inheritance-report/src/forms/sections/deceased.ts b/libs/application/templates/inheritance-report/src/forms/sections/deceased.ts index b679c626a58a..501dfc9f920c 100644 --- a/libs/application/templates/inheritance-report/src/forms/sections/deceased.ts +++ b/libs/application/templates/inheritance-report/src/forms/sections/deceased.ts @@ -84,7 +84,7 @@ export const deceased = buildSection({ }), buildDescriptionField({ id: 'space3', - space: 'gutter', + space: 'containerGutter', title: '', }), buildRadioField({ @@ -123,7 +123,7 @@ export const deceased = buildSection({ ), buildDescriptionField({ id: 'space4', - space: 'gutter', + space: 'containerGutter', title: '', }), buildRadioField({ diff --git a/libs/application/templates/inheritance-report/src/forms/sections/heirs.ts b/libs/application/templates/inheritance-report/src/forms/sections/heirs.ts index fd82a48babbf..5f6f38eb78eb 100644 --- a/libs/application/templates/inheritance-report/src/forms/sections/heirs.ts +++ b/libs/application/templates/inheritance-report/src/forms/sections/heirs.ts @@ -160,6 +160,27 @@ export const heirs = buildSection({ }), ], }), + buildSubSection({ + id: 'privateTransfer', + title: m.fileUploadPrivateTransfer, + children: [ + buildMultiField({ + id: 'heirsAdditionalInfo', + title: m.fileUploadPrivateTransfer, + description: m.uploadPrivateTransferUserGuidelines, + children: [ + buildFileUploadField({ + id: 'heirsAdditionalInfoPrivateTransferFiles', + uploadAccept: '.pdf, .doc, .docx, .jpg, .jpeg, .png, .xls, .xlsx', + uploadDescription: m.uploadPrivateTransferDescription, + title: '', + uploadHeader: '', + uploadMultiple: false, + }), + ], + }), + ], + }), buildSubSection({ id: 'heirsAdditionalInfo', title: m.heirAdditionalInfo, @@ -183,22 +204,6 @@ export const heirs = buildSection({ rows: 4, maxLength: 1800, }), - buildDescriptionField({ - id: 'heirsAdditionalInfoFilesPrivateTitle', - title: m.fileUploadPrivateTransfer, - description: m.uploadPrivateTransferUserGuidelines, - titleVariant: 'h5', - space: 'containerGutter', - marginBottom: 'smallGutter', - }), - buildFileUploadField({ - id: 'heirsAdditionalInfoPrivateTransferFiles', - uploadAccept: '.pdf, .doc, .docx, .jpg, .jpeg, .png, .xls, .xlsx', - uploadDescription: m.uploadPrivateTransferDescription, - title: '', - uploadHeader: '', - uploadMultiple: false, - }), buildDescriptionField({ id: 'heirsAdditionalInfoFilesOtherDocumentsTitle', title: m.fileUploadOtherDocuments, diff --git a/libs/application/templates/inheritance-report/src/lib/messages.ts b/libs/application/templates/inheritance-report/src/lib/messages.ts index 0f079f314435..c13308ad57bc 100644 --- a/libs/application/templates/inheritance-report/src/lib/messages.ts +++ b/libs/application/templates/inheritance-report/src/lib/messages.ts @@ -67,9 +67,14 @@ export const m = defineMessages({ description: '', }, // Application begin - applicationName: { - id: 'ir.application:applicationName', - defaultMessage: 'Erfðafjárskýrsla eftir andlát', + selectEstate: { + id: 'ir.application:selectEstate', + defaultMessage: 'Veldu dánarbú', + description: '', + }, + selectEstateDescription: { + id: 'ir.application:selectEstateDescription', + defaultMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', description: '', }, applicationNamePrepaid: { diff --git a/libs/application/types/src/lib/Fields.ts b/libs/application/types/src/lib/Fields.ts index 9ea37be69132..e5425ca66464 100644 --- a/libs/application/types/src/lib/Fields.ts +++ b/libs/application/types/src/lib/Fields.ts @@ -16,7 +16,7 @@ import { StaticText, } from './Form' import { ApolloClient } from '@apollo/client' -import { Application } from './Application' +import { Application, FormValue } from './Application' import { CallToAction } from './StateMachine' import { Colors, theme } from '@island.is/island-ui/theme' import { Condition } from './Condition' @@ -64,7 +64,7 @@ export type TagVariant = | 'mint' | 'disabled' -export type TableRepeaterFields = +export type RepeaterFields = | 'input' | 'select' | 'radio' @@ -82,8 +82,8 @@ type TableRepeaterOptions = activeField?: Record, ) => RepeaterOption[] | []) -export type TableRepeaterItem = { - component: TableRepeaterFields +export type RepeaterItem = { + component: RepeaterFields /** * Defaults to true */ @@ -253,6 +253,7 @@ export enum FieldTypes { NATIONAL_ID_WITH_NAME = 'NATIONAL_ID_WITH_NAME', ACTION_CARD_LIST = 'ACTION_CARD_LIST', TABLE_REPEATER = 'TABLE_REPEATER', + FIELDS_REPEATER = 'FIELDS_REPEATER', HIDDEN_INPUT = 'HIDDEN_INPUT', HIDDEN_INPUT_WITH_WATCHED_VALUE = 'HIDDEN_INPUT_WITH_WATCHED_VALUE', FIND_VEHICLE = 'FIND_VEHICLE', @@ -261,6 +262,7 @@ export enum FieldTypes { ACCORDION = 'ACCORDION', BANK_ACCOUNT = 'BANK_ACCOUNT', SLIDER = 'SLIDER', + DISPLAY = 'DISPLAY', } export enum FieldComponents { @@ -289,6 +291,7 @@ export enum FieldComponents { NATIONAL_ID_WITH_NAME = 'NationalIdWithNameFormField', ACTION_CARD_LIST = 'ActionCardListFormField', TABLE_REPEATER = 'TableRepeaterFormField', + FIELDS_REPEATER = 'FieldsRepeaterFormField', HIDDEN_INPUT = 'HiddenInputFormField', FIND_VEHICLE = 'FindVehicleFormField', VEHICLE_RADIO = 'VehicleRadioFormField', @@ -296,6 +299,7 @@ export enum FieldComponents { ACCORDION = 'AccordionFormField', BANK_ACCOUNT = 'BankAccountFormField', SLIDER = 'SliderFormField', + DISPLAY = 'DisplayFormField', } export interface CheckboxField extends InputField { @@ -639,7 +643,7 @@ export type TableRepeaterField = BaseField & { marginTop?: ResponsiveProp marginBottom?: ResponsiveProp titleVariant?: TitleVariants - fields: Record + fields: Record /** * Maximum rows that can be added to the table. * When the maximum is reached, the button to add a new row is disabled. @@ -659,6 +663,41 @@ export type TableRepeaterField = BaseField & { format?: Record string | StaticText> } } + +export type FieldsRepeaterField = BaseField & { + readonly type: FieldTypes.FIELDS_REPEATER + component: FieldComponents.FIELDS_REPEATER + titleVariant?: TitleVariants + formTitle?: StaticText + formTitleVariant?: TitleVariants + formTitleNumbering?: 'prefix' | 'suffix' | 'none' + removeItemButtonText?: StaticText + addItemButtonText?: StaticText + saveItemButtonText?: StaticText + marginTop?: ResponsiveProp + marginBottom?: ResponsiveProp + fields: Record + /** + * Maximum rows that can be added to the table. + * When the maximum is reached, the button to add a new row is disabled. + */ + minRows?: number + maxRows?: number + table?: { + /** + * List of strings to render, + * if not provided it will be auto generated from the fields + */ + header?: StaticText[] + /** + * List of field id's to render, + * if not provided it will be auto generated from the fields + */ + rows?: string[] + format?: Record string | StaticText> + } +} + export interface FindVehicleField extends InputField { readonly type: FieldTypes.FIND_VEHICLE component: FieldComponents.FIND_VEHICLE @@ -759,6 +798,19 @@ export interface SliderField extends BaseField { saveAsString?: boolean } +export interface DisplayField extends BaseField { + readonly type: FieldTypes.DISPLAY + component: FieldComponents.DISPLAY + marginTop?: ResponsiveProp + marginBottom?: ResponsiveProp + titleVariant?: TitleVariants + suffix?: MessageDescriptor | string + rightAlign?: boolean + variant?: TextFieldVariant + label?: MessageDescriptor | string + value: (answers: FormValue) => string +} + export type Field = | CheckboxField | CustomField @@ -786,6 +838,7 @@ export type Field = | NationalIdWithNameField | ActionCardListField | TableRepeaterField + | FieldsRepeaterField | HiddenInputWithWatchedValueField | HiddenInputField | FindVehicleField @@ -794,3 +847,4 @@ export type Field = | AccordionField | BankAccountField | SliderField + | DisplayField diff --git a/libs/application/ui-fields/src/lib/DisplayFormField/DisplayFormField.tsx b/libs/application/ui-fields/src/lib/DisplayFormField/DisplayFormField.tsx new file mode 100644 index 000000000000..ae6faba48fed --- /dev/null +++ b/libs/application/ui-fields/src/lib/DisplayFormField/DisplayFormField.tsx @@ -0,0 +1,80 @@ +import { formatTextWithLocale } from '@island.is/application/core' +import { DisplayField, FieldBaseProps } from '@island.is/application/types' +import { Box, Text } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { InputController } from '@island.is/shared/form-fields' +import { useEffect, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { Locale } from '@island.is/shared/types' + +interface Props extends FieldBaseProps { + field: DisplayField +} + +export const DisplayFormField = ({ field, application }: Props) => { + const { + value, + id, + title, + titleVariant = 'h4', + label, + variant, + suffix, + rightAlign = false, + } = field + const { watch, setValue } = useFormContext() + const allValues = watch() + const { formatMessage, lang: locale } = useLocale() + const [displayValue, setDisplayValue] = useState(allValues[id]) + + useEffect(() => { + const newDisplayValue = value(allValues) + setDisplayValue(newDisplayValue) + setValue(id, newDisplayValue) + }, [allValues]) + + return ( + + {title ? ( + + {formatTextWithLocale( + title, + application, + locale as Locale, + formatMessage, + )} + + ) : null} + + + + ) +} diff --git a/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.stories.mdx b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.stories.mdx new file mode 100644 index 000000000000..081dc5c3a1dc --- /dev/null +++ b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.stories.mdx @@ -0,0 +1,128 @@ +import { + Meta, + Story, + Canvas, + ArgsTable, + Description, + Source, +} from '@storybook/addon-docs' +import { dedent } from 'ts-dedent' + +import { FieldsRepeaterFormField } from './FieldsRepeaterFormField' + +export const createMockApplication = (data = {}) => ({ + id: '123', + assignees: [], + state: data.state || 'draft', + applicant: '111111-3000', + typeId: data.typeId || 'ExampleForm', + modified: new Date(), + created: new Date(), + attachments: {}, + answers: data.answers || {}, + externalData: data.externalData || {}, +}) + + + +# TableRepeaterFormField + +### Usage in a template + +You can create a FieldsRepeaterFormField using the following function `FieldsTableRepeaterField`. +Validation should be done via zod schema. + + + +The previous configuration object will result in the following component: + + + + value.replace(/^(.{3})/, '$1-'), + }, + }, + }} + /> + + + +You can also use this field into a custom component by using `` with the configuration object defined above. + +# Props + +## FieldsRepeaterFormField + + diff --git a/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.tsx b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.tsx new file mode 100644 index 000000000000..9edc6dd1890e --- /dev/null +++ b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.tsx @@ -0,0 +1,205 @@ +import { Fragment, useEffect, useState } from 'react' +import { + coreMessages, + formatText, + formatTextWithLocale, + getValueViaPath, +} from '@island.is/application/core' +import { + FieldBaseProps, + FieldsRepeaterField, +} from '@island.is/application/types' +import { + AlertMessage, + Box, + Button, + GridRow, + Stack, + Text, +} from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { FieldDescription } from '@island.is/shared/form-fields' +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form' +import { Item } from './FieldsRepeaterItem' +import { Locale } from '@island.is/shared/types' +import isEqual from 'lodash/isEqual' + +interface Props extends FieldBaseProps { + field: FieldsRepeaterField +} + +export const FieldsRepeaterFormField = ({ + application, + field: data, + showFieldName, + error, +}: Props) => { + const { + id, + fields: rawItems, + description, + marginTop = 6, + marginBottom, + title, + titleVariant = 'h2', + formTitle, + formTitleVariant = 'h4', + formTitleNumbering = 'suffix', + removeItemButtonText = coreMessages.buttonRemove, + addItemButtonText = coreMessages.buttonAdd, + minRows = 1, + maxRows, + } = data + + const { control, getValues, setValue } = useFormContext() + const answers = getValues() + const numberOfItemsInAnswers = getValueViaPath>( + answers, + id, + )?.length + const [numberOfItems, setNumberOfItems] = useState( + Math.max(numberOfItemsInAnswers ?? 0, minRows), + ) + const [updatedApplication, setUpdatedApplication] = useState(application) + + useEffect(() => { + if (!isEqual(application, updatedApplication)) { + setUpdatedApplication({ + ...application, + answers: { ...answers }, + }) + } + }, [answers]) + + const items = Object.keys(rawItems).map((key) => ({ + id: key, + ...rawItems[key], + })) + + const { formatMessage, lang: locale } = useLocale() + + const { fields, remove } = useFieldArray({ + control: control, + name: id, + }) + + const values = useWatch({ name: data.id, control: control }) + + const handleNewItem = () => { + setNumberOfItems(numberOfItems + 1) + } + + const handleRemoveItem = () => { + if (numberOfItems > (numberOfItemsInAnswers || 0)) { + setNumberOfItems(numberOfItems - 1) + } else if (numberOfItems === numberOfItemsInAnswers) { + setValue(id, answers[id].slice(0, -1)) + setNumberOfItems(numberOfItems - 1) + } else if ( + numberOfItemsInAnswers && + numberOfItems < numberOfItemsInAnswers + ) { + const difference = numberOfItems - numberOfItemsInAnswers + setValue(id, answers[id].slice(0, difference)) + setNumberOfItems(numberOfItems) + } + + remove(numberOfItems - 1) + } + + const repeaterFields = (index: number) => + items.map((item) => ( + + )) + + return ( + + {showFieldName && ( + + {formatTextWithLocale( + title, + application, + locale as Locale, + formatMessage, + )} + + )} + {description && ( + + )} + + + + {Array.from({ length: numberOfItems }).map((_i, i) => ( + + {(formTitleNumbering !== 'none' || formTitle) && ( + + + {formTitleNumbering === 'prefix' ? `${i + 1}. ` : ''} + {formTitle && + formatTextWithLocale( + formTitle, + application, + locale as Locale, + formatMessage, + )} + {formTitleNumbering === 'suffix' ? ` ${i + 1}` : ''} + + + )} + {repeaterFields(i)} + + ))} + + + {numberOfItems > minRows && ( + + + + )} + + + + {error && typeof error === 'string' && fields.length === 0 && ( + + + + )} + + + ) +} diff --git a/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterItem.tsx b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterItem.tsx new file mode 100644 index 000000000000..4fd041e97550 --- /dev/null +++ b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterItem.tsx @@ -0,0 +1,223 @@ +import { formatText, getValueViaPath } from '@island.is/application/core' +import { Application, RepeaterItem } from '@island.is/application/types' +import { GridColumn, Text } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { useEffect, useRef } from 'react' +import { useFormContext } from 'react-hook-form' +import isEqual from 'lodash/isEqual' +import { + CheckboxController, + DatePickerController, + InputController, + RadioController, + SelectController, + PhoneInputController, +} from '@island.is/shared/form-fields' +import { NationalIdWithName } from '@island.is/application/ui-components' + +interface ItemFieldProps { + application: Application + error?: string + item: RepeaterItem & { id: string } + dataId: string + index: number + values: Array> +} + +const componentMapper = { + input: InputController, + select: SelectController, + checkbox: CheckboxController, + date: DatePickerController, + radio: RadioController, + nationalIdWithName: NationalIdWithName, + phone: PhoneInputController, +} + +export const Item = ({ + application, + error, + item, + dataId, + index, + values, +}: ItemFieldProps) => { + const { formatMessage } = useLocale() + const { setValue, control, clearErrors } = useFormContext() + const prevWatchedValuesRef = useRef() + + const { + component, + id: itemId, + backgroundColor = 'blue', + label = '', + placeholder = '', + options, + width = 'full', + condition, + readonly = false, + disabled = false, + updateValueObj, + defaultValue, + ...props + } = item + const isHalfColumn = component !== 'radio' && width === 'half' + const isThirdColumn = component !== 'radio' && width === 'third' + const span = isHalfColumn ? '1/2' : isThirdColumn ? '1/3' : '1/1' + const Component = componentMapper[component] + const id = `${dataId}[${index}].${itemId}` + const activeValues = index >= 0 && values ? values[index] : undefined + + let watchedValues: string | (string | undefined)[] | undefined + if (updateValueObj) { + const watchedValuesId = + typeof updateValueObj.watchValues === 'function' + ? updateValueObj.watchValues(activeValues) + : updateValueObj.watchValues + + if (watchedValuesId) { + if (Array.isArray(watchedValuesId)) { + watchedValues = watchedValuesId.map((value) => { + return activeValues?.[`${value}`] + }) + } else { + watchedValues = activeValues?.[`${watchedValuesId}`] + } + } + } + + useEffect(() => { + // We need to deep compare the watched values to avoid unnecessary re-renders + if ( + watchedValues && + !isEqual(prevWatchedValuesRef.current, watchedValues) + ) { + prevWatchedValuesRef.current = watchedValues + if ( + updateValueObj && + watchedValues && + (Array.isArray(watchedValues) + ? !watchedValues.every((value) => value === undefined) + : true) + ) { + const finalValue = updateValueObj.valueModifier( + application, + activeValues, + ) + setValue(id, finalValue) + } + } + }, [watchedValues, updateValueObj, activeValues, setValue, id]) + + const getFieldError = (id: string) => { + /** + * Errors that occur in a field-array have incorrect typing + * This hack is needed to get the correct type + */ + const errorList = error as unknown as Record[] | undefined + const errors = errorList?.[index] + return errors?.[id] + } + + const getDefaultValue = ( + item: RepeaterItem, + application: Application, + activeField?: Record, + ) => { + const { defaultValue } = item + + if (defaultValue === undefined) { + return undefined + } + + return defaultValue(application, activeField) + } + + let translatedOptions: any = [] + if (typeof options === 'function') { + translatedOptions = options(application, activeValues) + } else { + translatedOptions = + options?.map((option) => ({ + ...option, + label: formatText(option.label, application, formatMessage), + ...(option.tooltip && { + tooltip: formatText(option.tooltip, application, formatMessage), + }), + })) ?? [] + } + + let Readonly: boolean | undefined + if (typeof readonly === 'function') { + Readonly = readonly(application, activeValues) + } else { + Readonly = readonly + } + + let Disabled: boolean | undefined + if (typeof disabled === 'function') { + Disabled = disabled(application, activeValues) + } else { + Disabled = disabled + } + + let DefaultValue: any + if (component === 'input') { + DefaultValue = getDefaultValue(item, application, activeValues) + } + if (component === 'select') { + DefaultValue = + getValueViaPath(application.answers, id) ?? + getDefaultValue(item, application, activeValues) + } + if (component === 'radio') { + DefaultValue = + (getValueViaPath(application.answers, id) as string[]) ?? + getDefaultValue(item, application, activeValues) + } + if (component === 'checkbox') { + DefaultValue = + (getValueViaPath(application.answers, id) as string[]) ?? + getDefaultValue(item, application, activeValues) + } + if (component === 'date') { + DefaultValue = + (getValueViaPath(application.answers, id) as string) ?? + getDefaultValue(item, application, activeValues) + } + + if (condition && !condition(application, activeValues)) { + return null + } + + return ( + + {component === 'radio' && label && ( + + {formatText(label, application, formatMessage)} + + )} + { + if (error) { + clearErrors(id) + } + }} + application={application} + defaultValue={DefaultValue} + {...props} + /> + + ) +} diff --git a/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx b/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx index dc5c01ae8309..5388065f176f 100644 --- a/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx +++ b/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx @@ -1,5 +1,5 @@ import { formatText, getValueViaPath } from '@island.is/application/core' -import { Application, TableRepeaterItem } from '@island.is/application/types' +import { Application, RepeaterItem } from '@island.is/application/types' import { GridColumn, Text } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { useEffect, useRef } from 'react' @@ -18,7 +18,7 @@ import { NationalIdWithName } from '@island.is/application/ui-components' interface ItemFieldProps { application: Application error?: string - item: TableRepeaterItem & { id: string } + item: RepeaterItem & { id: string } dataId: string activeIndex: number values: Array> @@ -121,7 +121,7 @@ export const Item = ({ } const getDefaultValue = ( - item: TableRepeaterItem, + item: RepeaterItem, application: Application, activeField?: Record, ) => { diff --git a/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts b/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts index f1b0c79ee077..7843b128e023 100644 --- a/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts +++ b/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts @@ -1,9 +1,9 @@ +import { RepeaterItem } from '@island.is/application/types' import { coreMessages } from '@island.is/application/core' -import { TableRepeaterItem } from '@island.is/application/types' type Item = { id: string -} & TableRepeaterItem +} & RepeaterItem export type Value = { [key: string]: T } @@ -42,7 +42,7 @@ const handleNationalIdWithNameItem = ( return newValues } -export const buildDefaultTableHeader = (items: Array) => +export const buildDefaultTableHeader = (items: Array) => items .map((item) => // nationalIdWithName is a special case where the value is an object of name and nationalId @@ -53,7 +53,7 @@ export const buildDefaultTableHeader = (items: Array) => .flat(2) export const buildDefaultTableRows = ( - items: Array, + items: Array, ) => items .map((item) => diff --git a/libs/application/ui-fields/src/lib/index.ts b/libs/application/ui-fields/src/lib/index.ts index c3e3f9724309..d796e89afbe9 100644 --- a/libs/application/ui-fields/src/lib/index.ts +++ b/libs/application/ui-fields/src/lib/index.ts @@ -24,9 +24,11 @@ export { NationalIdWithNameFormField } from './NationalIdWithNameFormField/Natio export { HiddenInputFormField } from './HiddenInputFormField/HiddenInputFormField' export { ActionCardListFormField } from './ActionCardListFormField/ActionCardListFormField' export { TableRepeaterFormField } from './TableRepeaterFormField/TableRepeaterFormField' +export { FieldsRepeaterFormField } from './FieldsRepeaterFormField/FieldsRepeaterFormField' export { FindVehicleFormField } from './FindVehicleFormField/FindVehicleFormField' export { VehicleRadioFormField } from './VehicleRadioFormField/VehicleRadioFormField' export { StaticTableFormField } from './StaticTableFormField/StaticTableFormField' export { AccordionFormField } from './AccordionFormField/AccordionFormField' export { BankAccountFormField } from './BankAccountFormField/BankAccountFormField' export { SliderFormField } from './SliderFormField/SliderFormField' +export { DisplayFormField } from './DisplayFormField/DisplayFormField' diff --git a/libs/auth-api-lib/src/index.ts b/libs/auth-api-lib/src/index.ts index c31273fdae15..6c4d00f2b791 100644 --- a/libs/auth-api-lib/src/index.ts +++ b/libs/auth-api-lib/src/index.ts @@ -61,6 +61,7 @@ export * from './lib/delegations/utils/scopes' export * from './lib/delegations/admin/delegation-admin-custom.service' export * from './lib/delegations/constants/names' export * from './lib/delegations/constants/zendesk' +export * from './lib/delegations/national-registry-v3-feature.service' // Resources module export * from './lib/resources/resources.module' diff --git a/libs/auth-api-lib/src/lib/delegations/alive-status.service.ts b/libs/auth-api-lib/src/lib/delegations/alive-status.service.ts new file mode 100644 index 000000000000..e1e4aeceb170 --- /dev/null +++ b/libs/auth-api-lib/src/lib/delegations/alive-status.service.ts @@ -0,0 +1,192 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import * as kennitala from 'kennitala' + +import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3' +import { CompanyRegistryClientService } from '@island.is/clients/rsk/company-registry' +import { LOGGER_PROVIDER } from '@island.is/logging' + +import { UNKNOWN_NAME } from './constants/names' +import { partitionWithIndex } from './utils/partitionWithIndex' + +export type NameInfo = { + nationalId: string + name: string +} + +type IdentityInfo = NameInfo & { isDeceased: boolean } + +const decesead = 'LÉST' + +@Injectable() +export class AliveStatusService { + constructor( + private readonly nationalRegistryClient: NationalRegistryClientService, + private readonly nationalRegistryV3Client: NationalRegistryV3ClientService, + private readonly companyRegistryClient: CompanyRegistryClientService, + @Inject(LOGGER_PROVIDER) + private logger: Logger, + ) {} + + /** + * Divides nationalIds into alive and deceased + * - Makes calls for every nationalId to NationalRegistry to check if the person exists. + * - Divides the nationalIds into alive and deceased, based on + * 1. All companies will be divided into alive. + * 2. If the person exists in NationalRegistry, then the person is alive. + */ + public async getStatus( + nameInfos: NameInfo[], + useNationalRegistryV3: boolean, + ): Promise<{ + aliveNationalIds: string[] + deceasedNationalIds: string[] + aliveNameInfo: NameInfo[] + }> { + if (nameInfos.length === 0) { + return { + aliveNationalIds: [], + deceasedNationalIds: [], + aliveNameInfo: [], + } + } + let identitiesValuesNoError: IdentityInfo[] = [] + try { + const identities = await ( + await Promise.allSettled( + this.getIdentities(nameInfos, useNationalRegistryV3), + ) + ).map((promiseResult) => + promiseResult.status === 'fulfilled' + ? promiseResult.value + : new Error('Error getting identity'), + ) + + identitiesValuesNoError = identities.filter(this.isNotError) + + const deceasedNationalIds = identitiesValuesNoError + .filter((identity) => identity.isDeceased) + .map((identity) => identity.nationalId) + + return { + aliveNationalIds: nameInfos + .filter((info) => !deceasedNationalIds.includes(info.nationalId)) + .map((info) => info.nationalId), + deceasedNationalIds: deceasedNationalIds, + aliveNameInfo: identitiesValuesNoError, + } + } catch (error) { + this.logger.error(`Error getting live status.`, error) + + // We do not want to fail the whole request if we cannot get the live status. + // Therefore, we return all nationalIds as alive. + return { + aliveNationalIds: nameInfos.map((info) => info.nationalId), + deceasedNationalIds: [], + aliveNameInfo: identitiesValuesNoError, + } + } + } + + private getIdentities( + nameInfos: NameInfo[], + useNationalRegistryV3: boolean, + ): Promise[] { + const [companies, individuals] = partitionWithIndex(nameInfos, (nameInfo) => + kennitala.isCompany(nameInfo.nationalId), + ) + + const companyPromises = companies.map((nameInfo) => + this.getCompanyIdentity(nameInfo), + ) + + const individualPromises = individuals.map((nameInfo) => + this.getIndividualIdentity(nameInfo, useNationalRegistryV3), + ) + + return [...companyPromises, ...individualPromises] + } + + private getCompanyIdentity(companyInfo: NameInfo): Promise { + // All companies will be divided into alive + return this.companyRegistryClient + .getCompany(companyInfo.nationalId) + .then((company) => { + if (company && this.isNotError(company)) { + return { + nationalId: company.nationalId, + name: company.name, + isDeceased: false, + } + } else { + return { + nationalId: companyInfo.nationalId, + name: companyInfo.name ?? UNKNOWN_NAME, + isDeceased: false, + } + } + }) + } + + private async getIndividualIdentity( + individualInfo: NameInfo, + useNationalRegistryV3: boolean, + ): Promise { + if (useNationalRegistryV3) { + return await this.nationalRegistryV3Client + .getAllDataIndividual(individualInfo.nationalId) + .then((individual) => { + if ( + individual && + this.isNotError(individual) && + individual?.kennitala && + individual?.kennitala !== null + ) { + return { + nationalId: individual.kennitala, + name: individual.nafn ?? individualInfo.name ?? UNKNOWN_NAME, + isDeceased: individual.afdrif === decesead, + } + } else { + // Pass through although Þjóðskrá API throws an error + return { + nationalId: individualInfo.nationalId, + name: individualInfo.name ?? UNKNOWN_NAME, + isDeceased: false, + } + } + }) + } else { + return await this.getIndividualIdentityV2(individualInfo) + } + } + + private getIndividualIdentityV2( + individualInfo: NameInfo, + ): Promise { + return this.nationalRegistryClient + .getIndividual(individualInfo.nationalId) + .then((individual) => { + if (individual === null) { + return { + nationalId: individualInfo.nationalId, + name: individualInfo.name ?? UNKNOWN_NAME, + isDeceased: true, + } + } else { + return { + nationalId: individual?.nationalId ?? individualInfo.nationalId, + name: individual?.name ?? individualInfo.name ?? UNKNOWN_NAME, + isDeceased: false, + } + } + }) + } + + /** + * Checks if item is not an instance of Error + */ + private isNotError(item: T | Error): item is T { + return item instanceof Error === false + } +} diff --git a/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts index d51a25e54cf9..ee60fb61c692 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts @@ -1,18 +1,12 @@ -import { Inject, Injectable, Logger } from '@nestjs/common' -import { InjectModel } from '@nestjs/sequelize' +import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' +import { InjectModel } from '@nestjs/sequelize' +import startOfDay from 'date-fns/startOfDay' import * as kennitala from 'kennitala' import uniqBy from 'lodash/uniqBy' import { Op } from 'sequelize' -import startOfDay from 'date-fns/startOfDay' import { User } from '@island.is/auth-nest-tools' -import { - IndividualDto, - NationalRegistryClientService, -} from '@island.is/clients/national-registry-v2' -import { CompanyRegistryClientService } from '@island.is/clients/rsk/company-registry' -import { LOGGER_PROVIDER } from '@island.is/logging' import { AuditService } from '@island.is/nest/audit' import { AuthDelegationType } from '@island.is/shared/types' import { isDefined } from '@island.is/shared/utils' @@ -20,17 +14,18 @@ import { isDefined } from '@island.is/shared/utils' import { ApiScopeDelegationType } from '../resources/models/api-scope-delegation-type.model' import { ApiScopeUserAccess } from '../resources/models/api-scope-user-access.model' import { ApiScope } from '../resources/models/api-scope.model' +import { AliveStatusService, NameInfo } from './alive-status.service' import { UNKNOWN_NAME } from './constants/names' +import { DelegationConfig } from './DelegationConfig' import { ApiScopeInfo } from './delegations-incoming.service' import { DelegationDTO } from './dto/delegation.dto' import { MergedDelegationDTO } from './dto/merged-delegation.dto' +import { DelegationDelegationType } from './models/delegation-delegation-type.model' import { DelegationScope } from './models/delegation-scope.model' import { Delegation } from './models/delegation.model' +import { NationalRegistryV3FeatureService } from './national-registry-v3-feature.service' import { DelegationValidity } from './types/delegationValidity' -import { partitionWithIndex } from './utils/partitionWithIndex' import { getScopeValidityWhereClause } from './utils/scopes' -import { DelegationDelegationType } from './models/delegation-delegation-type.model' -import { DelegationConfig } from './DelegationConfig' type FindAllValidIncomingOptions = { nationalId: string @@ -38,11 +33,6 @@ type FindAllValidIncomingOptions = { validity?: DelegationValidity } -type FromNameInfo = { - nationalId: string - name: string -} - /** * Service class for incoming delegations. * This class supports domain based delegations. @@ -54,13 +44,11 @@ export class DelegationsIncomingCustomService { private delegationModel: typeof Delegation, @InjectModel(ApiScopeUserAccess) private apiScopeUserAccessModel: typeof ApiScopeUserAccess, - private nationalRegistryClient: NationalRegistryClientService, - private companyRegistryClient: CompanyRegistryClientService, - @Inject(LOGGER_PROVIDER) - private logger: Logger, + private aliveStatusService: AliveStatusService, @Inject(DelegationConfig.KEY) private delegationConfig: ConfigType, private auditService: AuditService, + private readonly nationalRegistryV3FeatureService: NationalRegistryV3FeatureService, ) {} async findAllValidIncoming( @@ -70,6 +58,7 @@ export class DelegationsIncomingCustomService { validity = DelegationValidity.NOW, }: FindAllValidIncomingOptions, useMaster = false, + user?: User, ): Promise { const { delegations, fromNameInfo } = await this.findAllIncoming( { @@ -78,6 +67,7 @@ export class DelegationsIncomingCustomService { domainName, }, useMaster, + user, ) const delegationDTOs = delegations.map((d) => d.toDTO()) @@ -95,6 +85,7 @@ export class DelegationsIncomingCustomService { async findAllValidGeneralMandate( { nationalId }: FindAllValidIncomingOptions, useMaster = false, + user?: User, ): Promise { const { delegations, fromNameInfo } = await this.findAllIncomingGeneralMandates( @@ -102,6 +93,7 @@ export class DelegationsIncomingCustomService { nationalId, }, useMaster, + user, ) return delegations.map((delegation) => { @@ -133,10 +125,14 @@ export class DelegationsIncomingCustomService { return [] } - const { delegations, fromNameInfo } = await this.findAllIncoming({ - nationalId: user.nationalId, - validity: DelegationValidity.NOW, - }) + const { delegations, fromNameInfo } = await this.findAllIncoming( + { + nationalId: user.nationalId, + validity: DelegationValidity.NOW, + }, + false, + user, + ) const validDelegations = delegations .map((d) => { @@ -219,9 +215,13 @@ export class DelegationsIncomingCustomService { } const { delegations, fromNameInfo } = - await this.findAllIncomingGeneralMandates({ - nationalId: user.nationalId, - }) + await this.findAllIncomingGeneralMandates( + { + nationalId: user.nationalId, + }, + false, + user, + ) const mergedDelegationDTOs = uniqBy( delegations.map((d) => @@ -243,7 +243,8 @@ export class DelegationsIncomingCustomService { private async findAllIncomingGeneralMandates( { nationalId }: FindAllValidIncomingOptions, useMaster = false, - ): Promise<{ delegations: Delegation[]; fromNameInfo: FromNameInfo[] }> { + user?: User, + ): Promise<{ delegations: Delegation[]; fromNameInfo: NameInfo[] }> { const startOfToday = startOfDay(new Date()) const delegations = await this.delegationModel.findAll({ @@ -268,10 +269,22 @@ export class DelegationsIncomingCustomService { }) // Check live status, i.e. dead or alive for delegations - const { aliveDelegations, deceasedDelegations, fromNameInfo } = - await this.getLiveStatusFromDelegations(delegations) + const isNationalRegistryV3DeceasedStatusEnabled = + await this.nationalRegistryV3FeatureService.getValue(user) + + const { aliveNationalIds, deceasedNationalIds, aliveNameInfo } = + await this.aliveStatusService.getStatus( + delegations.map((d) => ({ + nationalId: d.fromNationalId, + name: d.fromDisplayName, + })), + isNationalRegistryV3DeceasedStatusEnabled, + ) - if (deceasedDelegations.length > 0) { + if (deceasedNationalIds.length > 0) { + const deceasedDelegations = delegations.filter((d) => + deceasedNationalIds.includes(d.fromNationalId), + ) // Delete all deceased delegations by deleting them and their scopes. const deletePromises = deceasedDelegations.map((delegation) => delegation.destroy(), @@ -286,7 +299,12 @@ export class DelegationsIncomingCustomService { }) } - return { delegations: aliveDelegations, fromNameInfo } + return { + delegations: delegations.filter((d) => + aliveNationalIds.includes(d.fromNationalId), + ), + fromNameInfo: aliveNameInfo, + } } private async findAllIncoming( @@ -298,7 +316,8 @@ export class DelegationsIncomingCustomService { validity: DelegationValidity }, useMaster = false, - ): Promise<{ delegations: Delegation[]; fromNameInfo: FromNameInfo[] }> { + user?: User, + ): Promise<{ delegations: Delegation[]; fromNameInfo: NameInfo[] }> { let whereOptions = getScopeValidityWhereClause(validity) if (domainName) whereOptions = { ...whereOptions, domainName: domainName } @@ -336,10 +355,22 @@ export class DelegationsIncomingCustomService { }) // Check live status, i.e. dead or alive for delegations - const { aliveDelegations, deceasedDelegations, fromNameInfo } = - await this.getLiveStatusFromDelegations(delegations) + const isNationalRegistryV3DeceasedStatusEnabled = + await this.nationalRegistryV3FeatureService.getValue(user) + + const { aliveNationalIds, deceasedNationalIds, aliveNameInfo } = + await this.aliveStatusService.getStatus( + delegations.map((d) => ({ + nationalId: d.fromNationalId, + name: d.fromDisplayName, + })), + isNationalRegistryV3DeceasedStatusEnabled, + ) - if (deceasedDelegations.length > 0) { + if (deceasedNationalIds.length > 0) { + const deceasedDelegations = delegations.filter((d) => + deceasedNationalIds.includes(d.fromNationalId), + ) // Delete all deceased delegations by deleting them and their scopes. const deletePromises = deceasedDelegations.map((delegation) => delegation.destroy(), @@ -354,7 +385,12 @@ export class DelegationsIncomingCustomService { }) } - return { delegations: aliveDelegations, fromNameInfo } + return { + delegations: delegations.filter((d) => + aliveNationalIds.includes(d.fromNationalId), + ), + fromNameInfo: aliveNameInfo, + } } private checkIfScopeIsValid( @@ -389,103 +425,14 @@ export class DelegationsIncomingCustomService { return false } - /** - * Divides delegations into alive and deceased delegations - * - Makes calls for every delegation to NationalRegistry to check if the person exists. - * - Divides the delegations into alive and deceased delegations, based on - * 1. All companies will be divided into alive delegations. - * 2. If the person exists in NationalRegistry, then the delegation is alive. - */ - private async getLiveStatusFromDelegations( - delegations: Delegation[], - ): Promise<{ - aliveDelegations: Delegation[] - deceasedDelegations: Delegation[] - fromNameInfo: FromNameInfo[] - }> { - if (delegations.length === 0) { - return { - aliveDelegations: [], - deceasedDelegations: [], - fromNameInfo: [], - } - } - - const delegationsPromises = delegations.map(({ fromNationalId }) => - kennitala.isCompany(fromNationalId) - ? this.companyRegistryClient - .getCompany(fromNationalId) - .catch(this.handlerGetError) - : this.nationalRegistryClient - .getIndividual(fromNationalId) - .catch(this.handlerGetError), - ) - - try { - // Check if delegations is linked to a person, i.e. not deceased - const identities = await Promise.all(delegationsPromises) - const identitiesValuesNoError = identities - .filter(this.isNotError) - .filter(isDefined) - .map((identity) => ({ - nationalId: identity.nationalId, - name: identity.name ?? UNKNOWN_NAME, - })) - - // Divide delegations into alive or deceased delegations. - const [aliveDelegations, deceasedDelegations] = partitionWithIndex( - delegations, - ({ fromNationalId }, index) => - // All companies will be divided into aliveDelegations - kennitala.isCompany(fromNationalId) || - // Pass through although Þjóðskrá API throws an error since it is not required to view the delegation. - identities[index] instanceof Error || - // Make sure we can match the person to the delegation, i.e. not deceased - (identities[index] as IndividualDto)?.nationalId === fromNationalId, - ) - - return { - aliveDelegations, - deceasedDelegations, - fromNameInfo: identitiesValuesNoError, - } - } catch (error) { - this.logger.error( - `Error getting live status from delegations. Delegations: ${delegations.map( - (d) => d.id, - )}`, - error, - ) - - // We do not want to fail the whole request if we cannot get the live status from delegations. - // Therefore, we return all delegations as alive delegations. - return { - aliveDelegations: delegations, - deceasedDelegations: [], - fromNameInfo: [], - } - } - } - - private handlerGetError(error: null | Error) { - return error - } - - /** - * Checks if item is not an instance of Error - */ - private isNotError(item: T | Error): item is T { - return item instanceof Error === false - } - /** * Finds person by nationalId. */ private getPersonByNationalId( - identities: Array, + identities: Array | undefined, nationalId: string, ) { - return identities.find((identity) => identity?.nationalId === nationalId) + return identities?.find((identity) => identity?.nationalId === nationalId) } private async findAccessControlList( diff --git a/libs/auth-api-lib/src/lib/delegations/delegations-incoming-representative.service.ts b/libs/auth-api-lib/src/lib/delegations/delegations-incoming-representative.service.ts index b4380ad4aef1..42d9ccb0a540 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations-incoming-representative.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations-incoming-representative.service.ts @@ -1,9 +1,6 @@ import { Inject, Logger } from '@nestjs/common' -import { - IndividualDto, - NationalRegistryClientService, -} from '@island.is/clients/national-registry-v2' +import { User } from '@island.is/auth-nest-tools' import { LOGGER_PROVIDER } from '@island.is/logging' import { AuditService } from '@island.is/nest/audit' import { @@ -14,10 +11,11 @@ import { isDefined } from '@island.is/shared/utils' import { PersonalRepresentativeDTO } from '../personal-representative/dto/personal-representative.dto' import { PersonalRepresentativeService } from '../personal-representative/services/personalRepresentative.service' +import { AliveStatusService } from './alive-status.service' import { UNKNOWN_NAME } from './constants/names' import { ApiScopeInfo } from './delegations-incoming.service' import { DelegationDTO } from './dto/delegation.dto' -import { partitionWithIndex } from './utils/partitionWithIndex' +import { NationalRegistryV3FeatureService } from './national-registry-v3-feature.service' type FindAllIncomingOptions = { nationalId: string @@ -28,10 +26,11 @@ type FindAllIncomingOptions = { export class DelegationsIncomingRepresentativeService { constructor( private prService: PersonalRepresentativeService, - private nationalRegistryClient: NationalRegistryClientService, + private aliveStatusService: AliveStatusService, @Inject(LOGGER_PROVIDER) private logger: Logger, private auditService: AuditService, + private readonly nationalRegistryV3FeatureService: NationalRegistryV3FeatureService, ) {} async findAllIncoming( @@ -41,6 +40,7 @@ export class DelegationsIncomingRepresentativeService { requireApiScopes, }: FindAllIncomingOptions, useMaster = false, + user?: User, ): Promise { if ( requireApiScopes && @@ -95,42 +95,35 @@ export class DelegationsIncomingRepresentativeService { } }) - const personPromises = personalRepresentatives.map( - ({ nationalIdRepresentedPerson }) => - this.nationalRegistryClient - .getIndividual(nationalIdRepresentedPerson) - .catch(this.handlerGetIndividualError), - ) + const isNationalRegistryV3DeceasedStatusEnabled = + await this.nationalRegistryV3FeatureService.getValue(user) - const persons = await Promise.all(personPromises) - const personsValues = persons.filter((person) => person !== undefined) - const personsValuesNoError = personsValues.filter(this.isNotError) - - // Divide personal representatives into alive or deceased. - const [alive, deceased] = partitionWithIndex( - personalRepresentatives, - ({ nationalIdRepresentedPerson }, index) => - // Pass through although Þjóðskrá API throws an error since it is not required to view the personal representative. - persons[index] instanceof Error || - // Make sure we can match the person to the personal representatives, i.e. not deceased - (persons[index] as IndividualDto)?.nationalId === - nationalIdRepresentedPerson, - ) + const { aliveNationalIds, deceasedNationalIds, aliveNameInfo } = + await this.aliveStatusService.getStatus( + personalRepresentatives.map((d) => ({ + nationalId: d.nationalIdRepresentedPerson, + name: UNKNOWN_NAME, + })), + isNationalRegistryV3DeceasedStatusEnabled, + ) - if (deceased.length > 0) { + if (deceasedNationalIds.length > 0) { + const deceased = personalRepresentatives.filter((pr) => + deceasedNationalIds.includes(pr.nationalIdRepresentedPerson), + ) await this.makePersonalRepresentativesInactive(deceased) } - return alive - .map((pr) => { - const person = this.getPersonByNationalId( - personsValuesNoError, - pr.nationalIdRepresentedPerson, - ) + const alive = personalRepresentatives.filter((pr) => + aliveNationalIds.includes(pr.nationalIdRepresentedPerson), + ) - return toDelegationDTO(person?.name ?? UNKNOWN_NAME, pr) - }) - .filter(isDefined) + return alive.map((pr) => { + const person = aliveNameInfo.find( + (n) => n.nationalId === pr.nationalIdRepresentedPerson, + ) + return toDelegationDTO(person?.name ?? UNKNOWN_NAME, pr) + }) } catch (error) { this.logger.error('Error in findAllRepresentedPersons', error) } @@ -154,25 +147,4 @@ export class DelegationsIncomingRepresentativeService { system: true, }) } - - private handlerGetIndividualError(error: null | Error) { - return error - } - - /** - * Finds person by nationalId. - */ - private getPersonByNationalId( - persons: Array, - nationalId: string, - ) { - return persons.find((person) => person?.nationalId === nationalId) - } - - /** - * Checks if item is not an instance of Error - */ - private isNotError(item: T | Error): item is T { - return item instanceof Error === false - } } diff --git a/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts b/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts index 1792164ddb48..b2e0775d3b48 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts @@ -2,10 +2,6 @@ import { BadRequestException, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/sequelize' import { User } from '@island.is/auth-nest-tools' -import { - IndividualDto, - NationalRegistryClientService, -} from '@island.is/clients/national-registry-v2' import { SyslumennService } from '@island.is/clients/syslumenn' import { logger } from '@island.is/logging' import { FeatureFlagService, Features } from '@island.is/nest/feature-flags' @@ -19,6 +15,7 @@ import { ClientDelegationType } from '../clients/models/client-delegation-type.m import { Client } from '../clients/models/client.model' import { ApiScopeDelegationType } from '../resources/models/api-scope-delegation-type.model' import { ApiScope } from '../resources/models/api-scope.model' +import { AliveStatusService, NameInfo } from './alive-status.service' import { UNKNOWN_NAME } from './constants/names' import { DelegationDTOMapper } from './delegation-dto.mapper' import { DelegationProviderService } from './delegation-provider.service' @@ -27,8 +24,10 @@ import { DelegationsIncomingCustomService } from './delegations-incoming-custom. import { DelegationsIncomingRepresentativeService } from './delegations-incoming-representative.service' import { DelegationsIncomingWardService } from './delegations-incoming-ward.service' import { DelegationsIndexService } from './delegations-index.service' +import { DelegationRecordDTO } from './dto/delegation-index.dto' import { DelegationDTO } from './dto/delegation.dto' import { MergedDelegationDTO } from './dto/merged-delegation.dto' +import { NationalRegistryV3FeatureService } from './national-registry-v3-feature.service' type ClientDelegationInfo = Pick< Client, @@ -66,9 +65,10 @@ export class DelegationsIncomingService { private delegationsIncomingWardService: DelegationsIncomingWardService, private delegationsIndexService: DelegationsIndexService, private delegationProviderService: DelegationProviderService, - private nationalRegistryClient: NationalRegistryClientService, + private aliveStatusService: AliveStatusService, private readonly featureFlagService: FeatureFlagService, private readonly syslumennService: SyslumennService, + private readonly nationalRegistryV3FeatureService: NationalRegistryV3FeatureService, ) {} async findAllValid( @@ -96,22 +96,34 @@ export class DelegationsIncomingService { ) delegationPromises.push( - this.delegationsIncomingCustomService.findAllValidIncoming({ - nationalId: user.nationalId, - domainName, - }), + this.delegationsIncomingCustomService.findAllValidIncoming( + { + nationalId: user.nationalId, + domainName, + }, + false, + user, + ), ) delegationPromises.push( - this.delegationsIncomingCustomService.findAllValidGeneralMandate({ - nationalId: user.nationalId, - }), + this.delegationsIncomingCustomService.findAllValidGeneralMandate( + { + nationalId: user.nationalId, + }, + false, + user, + ), ) delegationPromises.push( - this.delegationsIncomingRepresentativeService.findAllIncoming({ - nationalId: user.nationalId, - }), + this.delegationsIncomingRepresentativeService.findAllIncoming( + { + nationalId: user.nationalId, + }, + false, + user, + ), ) const delegationSets = await Promise.all(delegationPromises) @@ -211,11 +223,15 @@ export class DelegationsIncomingService { ) { delegationPromises.push( this.delegationsIncomingRepresentativeService - .findAllIncoming({ - nationalId: user.nationalId, - clientAllowedApiScopes, - requireApiScopes: client.requireApiScopes, - }) + .findAllIncoming( + { + nationalId: user.nationalId, + clientAllowedApiScopes, + requireApiScopes: client.requireApiScopes, + }, + false, + user, + ) .then((ds) => ds.map((d) => DelegationDTOMapper.toMergedDelegationDTO(d)), ), @@ -335,11 +351,64 @@ export class DelegationsIncomingService { clientAllowedApiScopes, requireApiScopes, ) - const merged = records.map((d) => - DelegationDTOMapper.recordToMergedDelegationDTO(d), - ) - await Promise.all(merged.map((d) => this.updateName(d))) + const isNationalRegistryV3DeceasedStatusEnabled = + await this.nationalRegistryV3FeatureService.getValue(user) + + const { aliveNationalIds, deceasedNationalIds, aliveNameInfo } = + await this.aliveStatusService.getStatus( + Array.from( + new Set( + records.map((d) => ({ + nationalId: d.fromNationalId, + name: UNKNOWN_NAME, + })), + ), + ), + isNationalRegistryV3DeceasedStatusEnabled, + ) + + if (deceasedNationalIds.length > 0) { + const deceasedDelegations = records.filter((d) => + deceasedNationalIds.includes(d.fromNationalId), + ) + // Delete all deceased delegations from index + const deletePromises = deceasedDelegations.map((delegation) => { + this.delegationsIndexService.removeDelegationRecord( + { + fromNationalId: delegation.fromNationalId, + toNationalId: delegation.toNationalId, + type: delegation.type, + provider: AuthDelegationProvider.DistrictCommissionersRegistry, + }, + user, + ) + }) + + const results = await Promise.allSettled(deletePromises) + results.forEach((result, index) => { + if (result.status === 'rejected') { + logger.error('Failed to remove delegation record', { + error: result.reason, + delegation: deceasedDelegations[index], + }) + } + }) + } + + const aliveNationalIdSet = new Set(aliveNationalIds) + const merged = records.reduce( + (acc: MergedDelegationDTO[], d: DelegationRecordDTO) => { + if (aliveNationalIdSet.has(d.fromNationalId)) { + acc.push({ + ...DelegationDTOMapper.recordToMergedDelegationDTO(d), + fromName: this.getNameFromNameInfo(d.fromNationalId, aliveNameInfo), + }) + } + return acc + }, + [], + ) return merged } @@ -381,17 +450,12 @@ export class DelegationsIncomingService { }) } - private async updateName( - mergedDelegation: MergedDelegationDTO, - ): Promise { - try { - const fromIndividual: IndividualDto | null = - await this.nationalRegistryClient.getIndividual( - mergedDelegation.fromNationalId, - ) - mergedDelegation.fromName = fromIndividual?.name ?? UNKNOWN_NAME - } catch (error) { - mergedDelegation.fromName = UNKNOWN_NAME - } + private getNameFromNameInfo( + nationalId: string, + nameInfo: NameInfo[], + ): string { + return ( + nameInfo.find((n) => n.nationalId === nationalId)?.name ?? UNKNOWN_NAME + ) } } diff --git a/libs/auth-api-lib/src/lib/delegations/delegations.module.ts b/libs/auth-api-lib/src/lib/delegations/delegations.module.ts index 21aae6fe43e4..031423b76886 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations.module.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations.module.ts @@ -3,10 +3,11 @@ import { SequelizeModule } from '@nestjs/sequelize' import { RskRelationshipsClientModule } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientModule } from '@island.is/clients/national-registry-v2' +import { NationalRegistryV3ClientModule } from '@island.is/clients/national-registry-v3' import { CompanyRegistryClientModule } from '@island.is/clients/rsk/company-registry' import { SyslumennClientModule } from '@island.is/clients/syslumenn' -import { FeatureFlagModule } from '@island.is/nest/feature-flags' import { ZendeskModule } from '@island.is/clients/zendesk' +import { FeatureFlagModule } from '@island.is/nest/feature-flags' import { ClientAllowedScope } from '../clients/models/client-allowed-scope.model' import { Client } from '../clients/models/client.model' @@ -19,6 +20,7 @@ import { ResourcesModule } from '../resources/resources.module' import { UserIdentitiesModule } from '../user-identities/user-identities.module' import { UserSystemNotificationModule } from '../user-notification' import { DelegationAdminCustomService } from './admin/delegation-admin-custom.service' +import { AliveStatusService } from './alive-status.service' import { DelegationProviderService } from './delegation-provider.service' import { DelegationScopeService } from './delegation-scope.service' import { IncomingDelegationsCompanyService } from './delegations-incoming-company.service' @@ -37,12 +39,14 @@ import { DelegationScope } from './models/delegation-scope.model' import { DelegationTypeModel } from './models/delegation-type.model' import { Delegation } from './models/delegation.model' import { NamesService } from './names.service' +import { NationalRegistryV3FeatureService } from './national-registry-v3-feature.service' @Module({ imports: [ ResourcesModule, PersonalRepresentativeModule, NationalRegistryClientModule, + NationalRegistryV3ClientModule, RskRelationshipsClientModule, CompanyRegistryClientModule, ZendeskModule, @@ -79,6 +83,8 @@ import { NamesService } from './names.service' DelegationsIndexService, DelegationProviderService, DelegationAdminCustomService, + AliveStatusService, + NationalRegistryV3FeatureService, ], exports: [ DelegationsService, diff --git a/libs/auth-api-lib/src/lib/delegations/national-registry-v3-feature.service.ts b/libs/auth-api-lib/src/lib/delegations/national-registry-v3-feature.service.ts new file mode 100644 index 000000000000..e750dbc12126 --- /dev/null +++ b/libs/auth-api-lib/src/lib/delegations/national-registry-v3-feature.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common' + +import { User } from '@island.is/auth-nest-tools' +import { FeatureFlagService, Features } from '@island.is/nest/feature-flags' + +@Injectable() +export class NationalRegistryV3FeatureService { + constructor(private readonly featureFlagService: FeatureFlagService) {} + + getValue(user?: User): Promise { + return this.featureFlagService.getValue( + Features.isNationalRegistryV3DeceasedStatusEnabled, + false, + user, + ) + } +} diff --git a/libs/cms/src/lib/cms.contentful.service.ts b/libs/cms/src/lib/cms.contentful.service.ts index f54e8a4794c9..4488f25f5201 100644 --- a/libs/cms/src/lib/cms.contentful.service.ts +++ b/libs/cms/src/lib/cms.contentful.service.ts @@ -83,10 +83,11 @@ import { LifeEventPage, mapLifeEventPage } from './models/lifeEventPage.model' import { GetGenericTagBySlugInput } from './dto/getGenericTagBySlug.input' import { GetGenericTagsInTagGroupsInput } from './dto/getGenericTagsInTagGroups.input' import { Grant, mapGrant } from './models/grant.model' -import { GrantList } from './models/grantList.model' import { mapManual } from './models/manual.model' import { mapServiceWebPage } from './models/serviceWebPage.model' import { mapEvent } from './models/event.model' +import { GetOrganizationParentSubpageInput } from './dto/getOrganizationParentSubpage.input' +import { mapOrganizationParentSubpage } from './models/organizationParentSubpage.model' const errorHandler = (name: string) => { return (error: Error) => { @@ -1151,4 +1152,27 @@ export class CmsContentfulService { return (result.items as types.IGenericTag[]).map(mapGenericTag) } + + async getOrganizationParentSubpage(input: GetOrganizationParentSubpageInput) { + const params = { + content_type: 'organizationParentSubpage', + 'fields.slug': input.slug, + 'fields.organizationPage.sys.contentType.sys.id': 'organizationPage', + 'fields.organizationPage.fields.slug': input.organizationPageSlug, + limit: 1, + } + + const response = await this.contentfulRepository + .getLocalizedEntries( + input.lang, + params, + ) + .catch(errorHandler('getOrganizationParentSubpage')) + + return ( + (response.items as types.IOrganizationParentSubpage[]).map( + mapOrganizationParentSubpage, + )[0] ?? null + ) + } } diff --git a/libs/cms/src/lib/cms.resolver.ts b/libs/cms/src/lib/cms.resolver.ts index 462b5e4531ec..8c82719d6bdc 100644 --- a/libs/cms/src/lib/cms.resolver.ts +++ b/libs/cms/src/lib/cms.resolver.ts @@ -126,6 +126,8 @@ import { Grant } from './models/grant.model' import { GetGrantsInput } from './dto/getGrants.input' import { GetSingleGrantInput } from './dto/getSingleGrant.input' import { GrantList } from './models/grantList.model' +import { OrganizationParentSubpage } from './models/organizationParentSubpage.model' +import { GetOrganizationParentSubpageInput } from './dto/getOrganizationParentSubpage.input' const defaultCache: CacheControlOptions = { maxAge: CACHE_CONTROL_MAX_AGE } @@ -707,6 +709,14 @@ export class CmsResolver { ): Promise { return this.cmsElasticsearchService.getTeamMembers(input) } + + @CacheControl(defaultCache) + @Query(() => OrganizationParentSubpage, { nullable: true }) + getOrganizationParentSubpage( + @Args('input') input: GetOrganizationParentSubpageInput, + ): Promise { + return this.cmsContentfulService.getOrganizationParentSubpage(input) + } } @Resolver(() => LatestNewsSlice) diff --git a/libs/cms/src/lib/dto/getOrganizationParentSubpage.input.ts b/libs/cms/src/lib/dto/getOrganizationParentSubpage.input.ts new file mode 100644 index 000000000000..c45e5ef9fd33 --- /dev/null +++ b/libs/cms/src/lib/dto/getOrganizationParentSubpage.input.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsString } from 'class-validator' +import { ElasticsearchIndexLocale } from '@island.is/content-search-index-manager' + +@InputType() +export class GetOrganizationParentSubpageInput { + @Field() + @IsString() + slug!: string + + @Field() + @IsString() + organizationPageSlug!: string + + @Field(() => String) + @IsString() + lang: ElasticsearchIndexLocale = 'is' +} diff --git a/libs/cms/src/lib/models/organizationParentSubpage.model.ts b/libs/cms/src/lib/models/organizationParentSubpage.model.ts new file mode 100644 index 000000000000..9c9a4d927059 --- /dev/null +++ b/libs/cms/src/lib/models/organizationParentSubpage.model.ts @@ -0,0 +1,48 @@ +import { CacheField } from '@island.is/nest/graphql' +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { IOrganizationParentSubpage } from '../generated/contentfulTypes' +import { getOrganizationPageUrlPrefix } from '@island.is/shared/utils' + +@ObjectType() +class OrganizationSubpageLink { + @Field() + label!: string + + @Field() + href!: string +} + +@ObjectType() +export class OrganizationParentSubpage { + @Field(() => ID) + id!: string + + @Field() + title!: string + + @CacheField(() => [OrganizationSubpageLink]) + childLinks!: OrganizationSubpageLink[] +} + +export const mapOrganizationParentSubpage = ({ + sys, + fields, +}: IOrganizationParentSubpage): OrganizationParentSubpage => { + return { + id: sys.id, + title: fields.title, + childLinks: + fields.pages + ?.filter( + (page) => + Boolean(page.fields.organizationPage?.fields?.slug) && + Boolean(page.fields.slug), + ) + .map((page) => ({ + label: page.fields.title, + href: `/${getOrganizationPageUrlPrefix(sys.locale)}/${ + page.fields.organizationPage.fields.slug + }/${fields.slug}/${page.fields.slug}`, + })) ?? [], + } +} diff --git a/libs/feature-flags/src/lib/features.ts b/libs/feature-flags/src/lib/features.ts index ae839cdd1690..fc77efa64398 100644 --- a/libs/feature-flags/src/lib/features.ts +++ b/libs/feature-flags/src/lib/features.ts @@ -106,6 +106,9 @@ export enum Features { // General mandate delegation type isGeneralMandateDelegationEnabled = 'isGeneralMandateDelegationEnabled', + + // Should auth api use national registry v3 for checking deceased status + isNationalRegistryV3DeceasedStatusEnabled = 'isNationalRegistryV3DeceasedStatusEnabled', } export enum ServerSideFeature { diff --git a/libs/judicial-system/types/src/index.ts b/libs/judicial-system/types/src/index.ts index 2a7cf9901626..140f6c9bc92a 100644 --- a/libs/judicial-system/types/src/index.ts +++ b/libs/judicial-system/types/src/index.ts @@ -102,6 +102,7 @@ export { export { getIndictmentVerdictAppealDeadlineStatus, VERDICT_APPEAL_WINDOW_DAYS, + FINE_APPEAL_WINDOW_DAYS, } from './lib/indictmentCase' export type { diff --git a/libs/judicial-system/types/src/lib/indictmentCase.ts b/libs/judicial-system/types/src/lib/indictmentCase.ts index d751d60616b5..f823acbdf4b4 100644 --- a/libs/judicial-system/types/src/lib/indictmentCase.ts +++ b/libs/judicial-system/types/src/lib/indictmentCase.ts @@ -1,6 +1,7 @@ const DAYS_TO_MILLISECONDS = 24 * 60 * 60 * 1000 export const VERDICT_APPEAL_WINDOW_DAYS = 28 -const MILLISECONDS_TO_EXPIRY = VERDICT_APPEAL_WINDOW_DAYS * DAYS_TO_MILLISECONDS +export const FINE_APPEAL_WINDOW_DAYS = 3 +const getDays = (days: number) => days * DAYS_TO_MILLISECONDS /* This function takes an array of verdict info tuples: @@ -12,6 +13,7 @@ const MILLISECONDS_TO_EXPIRY = VERDICT_APPEAL_WINDOW_DAYS * DAYS_TO_MILLISECONDS */ export const getIndictmentVerdictAppealDeadlineStatus = ( verdictInfo?: [boolean, Date | undefined][], + isFine?: boolean, ): [boolean, boolean] => { if ( !verdictInfo || @@ -34,5 +36,10 @@ export const getIndictmentVerdictAppealDeadlineStatus = ( new Date(0), ) - return [true, Date.now() > newestViewDate.getTime() + MILLISECONDS_TO_EXPIRY] + return [ + true, + Date.now() > + newestViewDate.getTime() + + getDays(isFine ? FINE_APPEAL_WINDOW_DAYS : VERDICT_APPEAL_WINDOW_DAYS), + ] } diff --git a/libs/portals/admin/delegation-admin/src/screens/CreateDelegation/CreateDelegation.tsx b/libs/portals/admin/delegation-admin/src/screens/CreateDelegation/CreateDelegation.tsx index 489fdf9c84f3..7e25f55962b6 100644 --- a/libs/portals/admin/delegation-admin/src/screens/CreateDelegation/CreateDelegation.tsx +++ b/libs/portals/admin/delegation-admin/src/screens/CreateDelegation/CreateDelegation.tsx @@ -1,54 +1,53 @@ -import React, { useEffect, useState } from 'react' import { - Box, - Stack, AlertMessage, + Box, Checkbox, + DatePicker, GridColumn, GridRow, + Icon, Input, Select, - DatePicker, + Stack, toast, - Icon, } from '@island.is/island-ui/core' -import { BackButton } from '@island.is/portals/admin/core' import { useLocale } from '@island.is/localization' +import { BackButton } from '@island.is/portals/admin/core' +import React, { useEffect, useState } from 'react' +import { Identity } from '@island.is/api/schema' import { IntroHeader, m as coreMessages } from '@island.is/portals/core' -import { m } from '../../lib/messages' -import { DelegationAdminPaths } from '../../lib/paths' -import NumberFormat from 'react-number-format' +import { + DelegationsFormFooter, + useDynamicShadow, +} from '@island.is/portals/shared-modules/delegations' +import { useUserInfo } from '@island.is/react-spa/bff' +import { replaceParams } from '@island.is/react-spa/shared' +import { AuthDelegationType } from '@island.is/shared/types' +import { maskString, unmaskString } from '@island.is/shared/utils' +import cn from 'classnames' import startOfDay from 'date-fns/startOfDay' +import kennitala from 'kennitala' +import debounce from 'lodash/debounce' +import NumberFormat from 'react-number-format' import { Form, - redirect, useActionData, useNavigate, useSearchParams, useSubmit, } from 'react-router-dom' +import { CreateDelegationConfirmModal } from '../../components/CreateDelegationConfirmModal' +import { FORM_ERRORS } from '../../constants/errors' +import { m } from '../../lib/messages' +import { DelegationAdminPaths } from '../../lib/paths' import { CreateDelegationResult } from './CreateDelegation.action' import * as styles from './CreateDelegation.css' import { useIdentityLazyQuery } from './CreateDelegation.generated' -import debounce from 'lodash/debounce' -import cn from 'classnames' -import { - DelegationsFormFooter, - useDynamicShadow, -} from '@island.is/portals/shared-modules/delegations' -import { CreateDelegationConfirmModal } from '../../components/CreateDelegationConfirmModal' -import { Identity } from '@island.is/api/schema' -import kennitala from 'kennitala' -import { maskString, unmaskString } from '@island.is/shared/utils' -import { useAuth } from '@island.is/auth/react' -import { replaceParams } from '@island.is/react-spa/shared' -import { FORM_ERRORS } from '../../constants/errors' -import { AuthDelegationType } from '@island.is/shared/types' const CreateDelegationScreen = () => { const { formatMessage } = useLocale() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() diff --git a/libs/portals/admin/delegation-admin/src/screens/DelegationAdminDetails/DelegationAdmin.tsx b/libs/portals/admin/delegation-admin/src/screens/DelegationAdminDetails/DelegationAdmin.tsx index daea025576c2..fa47e3700ce1 100644 --- a/libs/portals/admin/delegation-admin/src/screens/DelegationAdminDetails/DelegationAdmin.tsx +++ b/libs/portals/admin/delegation-admin/src/screens/DelegationAdminDetails/DelegationAdmin.tsx @@ -1,24 +1,23 @@ -import { Button, GridColumn, Box, Stack, Tabs } from '@island.is/island-ui/core' -import { BackButton } from '@island.is/portals/admin/core' -import { useLocale } from '@island.is/localization' -import { useLoaderData, useNavigate } from 'react-router-dom' -import { DelegationAdminResult } from './DelegationAdmin.loader' -import { DelegationAdminPaths } from '../../lib/paths' -import { formatNationalId, IntroHeader } from '@island.is/portals/core' -import { m } from '../../lib/messages' -import React from 'react' -import DelegationList from '../../components/DelegationList' import { AuthCustomDelegation } from '@island.is/api/schema' -import { DelegationsEmptyState } from '@island.is/portals/shared-modules/delegations' -import { useAuth } from '@island.is/auth/react' import { AdminPortalScope } from '@island.is/auth/scopes' +import { Box, Button, GridColumn, Stack, Tabs } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { BackButton } from '@island.is/portals/admin/core' +import { IntroHeader, formatNationalId } from '@island.is/portals/core' +import { DelegationsEmptyState } from '@island.is/portals/shared-modules/delegations' +import { useUserInfo } from '@island.is/react-spa/bff' import { maskString } from '@island.is/shared/utils' +import { useLoaderData, useNavigate } from 'react-router-dom' +import DelegationList from '../../components/DelegationList' +import { m } from '../../lib/messages' +import { DelegationAdminPaths } from '../../lib/paths' +import { DelegationAdminResult } from './DelegationAdmin.loader' const DelegationAdminScreen = () => { const { formatMessage } = useLocale() const navigate = useNavigate() const delegationAdmin = useLoaderData() as DelegationAdminResult - const { userInfo } = useAuth() + const userInfo = useUserInfo() const hasAdminAccess = userInfo?.scopes.includes( AdminPortalScope.delegationSystemAdmin, diff --git a/libs/portals/admin/delegation-admin/src/screens/Root.tsx b/libs/portals/admin/delegation-admin/src/screens/Root.tsx index 3dd3600dd6c8..d7e8866b9f3f 100644 --- a/libs/portals/admin/delegation-admin/src/screens/Root.tsx +++ b/libs/portals/admin/delegation-admin/src/screens/Root.tsx @@ -1,20 +1,20 @@ -import { useLocale } from '@island.is/localization' -import { IntroHeader } from '@island.is/portals/core' -import { m } from '../lib/messages' -import { Form, Outlet, useActionData, useNavigate } from 'react-router-dom' +import { AdminPortalScope } from '@island.is/auth/scopes' import { AsyncSearchInput, + Box, Button, GridColumn, GridRow, - Box, } from '@island.is/island-ui/core' -import React, { useEffect, useState } from 'react' +import { useLocale } from '@island.is/localization' +import { IntroHeader } from '@island.is/portals/core' +import { useUserInfo } from '@island.is/react-spa/bff' import { useSubmitting } from '@island.is/react-spa/shared' -import { GetDelegationForNationalIdResult } from './Root.action' +import { useEffect, useState } from 'react' +import { Form, Outlet, useActionData, useNavigate } from 'react-router-dom' +import { m } from '../lib/messages' import { DelegationAdminPaths } from '../lib/paths' -import { useAuth } from '@island.is/auth/react' -import { AdminPortalScope } from '@island.is/auth/scopes' +import { GetDelegationForNationalIdResult } from './Root.action' const Root = () => { const [focused, setFocused] = useState(false) @@ -24,7 +24,7 @@ const Root = () => { const { isSubmitting, isLoading } = useSubmitting() const [error, setError] = useState({ hasError: false, message: '' }) const navigate = useNavigate() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const hasAdminAccess = userInfo?.scopes.includes( AdminPortalScope.delegationSystemAdmin, diff --git a/libs/portals/admin/regulations-admin/src/components/EditBasics.tsx b/libs/portals/admin/regulations-admin/src/components/EditBasics.tsx index 304a1e004f20..b2868d63fe0f 100644 --- a/libs/portals/admin/regulations-admin/src/components/EditBasics.tsx +++ b/libs/portals/admin/regulations-admin/src/components/EditBasics.tsx @@ -29,6 +29,7 @@ import { ReferenceText } from './impacts/ReferenceText' import { DraftChangeForm, DraftImpactForm } from '../state/types' import { makeDraftAppendixForm } from '../state/makeFields' import { hasAnyChange } from '../utils/formatAmendingUtils' +import { usePristineRegulations } from '../utils/hooks' const updateText = 'Ósamræmi er í texta stofnreglugerðar og breytingareglugerðar. Texti breytingareglugerðar þarf að samræmast breytingum sem gerðar hafa verið á stofnreglugerð, eigi breytingarnar að færast inn með réttum hætti.' @@ -38,12 +39,13 @@ export const EditBasics = () => { const { draft, actions, ministries } = useDraftingState() const [editorKey, setEditorKey] = useState('initial') const [titleError, setTitleError] = useState(undefined) - const [hasUpdated, setHasUpdated] = useState(false) const [hasUpdatedAppendix, setHasUpdatedAppendix] = useState(false) const [references, setReferences] = useState() const [isModalVisible, setIsModalVisible] = useState(true) const [hasConfirmed, setHasConfirmed] = useState(false) const [hasSeenModal, setHasSeenModal] = useState(false) + const { removePristineRegulation, isPristineRegulation } = + usePristineRegulations() const { text, appendixes } = draft const { updateState } = actions @@ -120,13 +122,6 @@ export const EditBasics = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [draft.impacts]) - useEffect(() => { - if (!hasUpdatedAppendix && hasUpdated) { - updateAppendixes() - setHasUpdatedAppendix(true) - } - }, [hasUpdated, hasUpdatedAppendix]) - const updateAppendixes = () => { // FORMAT AMENDING REGULATION APPENDIXES const impactArray = Object.values(draft.impacts).flat() @@ -167,9 +162,17 @@ export const EditBasics = () => { const additionString = additions.join('') as HTMLText updateState('text', additionString) - setHasUpdated(true) + // Update appendixes + if (!hasUpdatedAppendix) { + updateAppendixes() + setHasUpdatedAppendix(true) + } + + // Remove from session storage + removePristineRegulation(draft.id) } + const shouldShowModal = isPristineRegulation(draft.id) return ( <> @@ -328,7 +331,7 @@ export const EditBasics = () => { )} - {!hasUpdated ? ( + {shouldShowModal ? ( { const chaptersField = draft.lawChapters const activeChapters = chaptersField.value - const { data: mentionedList /*, loading , error */ } = - useRegulationListQuery(draft.mentioned) + // const { data: mentionedList /*, loading , error */ } = + // useRegulationListQuery(draft.mentioned) const lawChaptersOptions = useLawChapterOptions( lawChapters.list, @@ -47,16 +47,17 @@ export const LawChaptersSelect = () => { ) // Auto fill lawChapters if there are mentions and no lawchapters present - useEffect(() => { - if (mentionedList?.length && !draft.lawChapters.value.length) { - mentionedList.forEach((mention) => { - mention.lawChapters?.forEach((ch) => { - actions.updateLawChapterProp('add', ch.slug) - }) - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mentionedList]) + // REMOVE FOR NOW. NOT SHOWING CORRECT RESULTS. MIGHT WANT TO REVISIT + // useEffect(() => { + // if (mentionedList?.length && !draft.lawChapters.value.length) { + // mentionedList.forEach((mention) => { + // mention.lawChapters?.forEach((ch) => { + // actions.updateLawChapterProp('add', ch.slug) + // }) + // }) + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [mentionedList]) return ( diff --git a/libs/portals/admin/regulations-admin/src/components/impacts/EditChange.tsx b/libs/portals/admin/regulations-admin/src/components/impacts/EditChange.tsx index aad98a8ca232..443c1545203b 100644 --- a/libs/portals/admin/regulations-admin/src/components/impacts/EditChange.tsx +++ b/libs/portals/admin/regulations-admin/src/components/impacts/EditChange.tsx @@ -7,6 +7,7 @@ import { GridRow, GridColumn, Text, + toast, } from '@island.is/island-ui/core' import { useEffect, useState, useMemo } from 'react' import { @@ -51,7 +52,10 @@ import { updateFieldValue, validateImpact, } from '../../state/validations' -import { useGetRegulationHistory } from '../../utils/hooks' +import { + useGetRegulationHistory, + usePristineRegulations, +} from '../../utils/hooks' import { DraftRegulationChange } from '@island.is/regulations/admin' import { useLocale } from '@island.is/localization' import { cleanTitle } from '@island.is/regulations-tools/cleanTitle' @@ -74,6 +78,7 @@ export const EditChange = (props: EditChangeProp) => { const today = useMemo(() => new Date(), []) const [minDate, setMinDate] = useState() const [showChangeForm, setShowChangeForm] = useState(false) + const { addPristineRegulation } = usePristineRegulations() // Target regulation for impact const [activeRegulation, setActiveRegulation] = useState< @@ -280,7 +285,12 @@ export const EditChange = (props: EditChangeProp) => { } return { success: true, error: undefined } }) + .then(() => { + addPristineRegulation(draft.id) + closeModal(true) + }) .catch((error) => { + toast.error(t(msg.errorOnSaveReg)) return { success: false, error: error as Error } }) } else { @@ -306,12 +316,15 @@ export const EditChange = (props: EditChangeProp) => { } return { success: true, error: undefined } }) + .then(() => { + addPristineRegulation(draft.id) + closeModal(true) + }) .catch((error) => { + toast.error(t(msg.errorOnSaveReg)) return { success: false, error: error as Error } }) } - - closeModal(true) } const localActions = { diff --git a/libs/portals/admin/regulations-admin/src/components/impacts/ImpactAmendingSelection.tsx b/libs/portals/admin/regulations-admin/src/components/impacts/ImpactAmendingSelection.tsx index ec8ab460d01d..109679e96c38 100644 --- a/libs/portals/admin/regulations-admin/src/components/impacts/ImpactAmendingSelection.tsx +++ b/libs/portals/admin/regulations-admin/src/components/impacts/ImpactAmendingSelection.tsx @@ -2,7 +2,11 @@ import { useEffect, useState } from 'react' import { impactMsgs } from '../../lib/messages' import { useLocale } from '@island.is/localization' -import { RegulationOptionList, RegulationType } from '@island.is/regulations' +import { + RegulationOption, + RegulationOptionList, + RegulationType, +} from '@island.is/regulations' import { DraftImpactName } from '@island.is/regulations/admin' import { AsyncSearch, Option, Text } from '@island.is/island-ui/core' @@ -39,10 +43,24 @@ export const ImpactAmendingSelection = ({ RegulationSearchListQuery, { onCompleted: (data) => { - setResults( - (data.getRegulationsOptionSearch as RegulationOptionList) || - undefined, - ) + // The search results need to be reordered so that the objects with titles containing the input string are first + const results = data.getRegulationsOptionSearch + const reorderedResults = value + ? [ + // Objects with titles containing the input string + ...results.filter((obj: RegulationOption) => + obj.title.toLowerCase().includes(value.toLowerCase()), + ), + // The rest of the objects + ...results.filter( + (obj: RegulationOption) => + !obj.title.toLowerCase().includes(value.toLowerCase()), + ), + ] + : results // If value is undefined, keep the original order + + setIsLoading(false) + setResults((reorderedResults as RegulationOptionList) || undefined) }, fetchPolicy: 'no-cache', }, @@ -64,7 +82,6 @@ export const ImpactAmendingSelection = ({ variables: { input: { q: value, iA: false, iR: false } }, }) } - setIsLoading(false) }, 600, [value], diff --git a/libs/portals/admin/regulations-admin/src/lib/messages.ts b/libs/portals/admin/regulations-admin/src/lib/messages.ts index e49a79d65275..1483376cd6af 100644 --- a/libs/portals/admin/regulations-admin/src/lib/messages.ts +++ b/libs/portals/admin/regulations-admin/src/lib/messages.ts @@ -571,6 +571,11 @@ export const errorMsgs = defineMessages({ id: 'ap.regulations-admin:title-is-too-long', defaultMessage: 'Titill reglugerðar er of langur.', }, + errorOnSaveReg: { + id: 'ap.regulations-admin:error-on-save-reg', + defaultMessage: + 'Villa kom upp við að vista texta. Vinsamlegast afritið texta og reynið aftur síðar.', + }, }) export const homeMessages = defineMessages({ diff --git a/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts b/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts index d42974eb109f..651408bdf618 100644 --- a/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts +++ b/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts @@ -9,11 +9,15 @@ import flatten from 'lodash/flatten' import uniq from 'lodash/uniq' import { allSameDay, + containsAnyTitleClass, extractArticleTitleDisplay, formatDate, + getArticleTitleType, + getArticleTypeText, getTextWithSpaces, groupElementsByArticleTitleFromDiv, hasAnyChange, + hasSubtitle, isGildisTaka, removeRegPrefix, updateAppendixWording, @@ -203,13 +207,21 @@ export const formatAmendingRegBody = ( const testGroup: { arr: HTMLText[] original?: HTMLText[] - title: string + titleObject: { + text: string + hasSubtitle: boolean + type: 'article' | 'chapter' | 'subchapter' + } isDeletion?: boolean isAddition?: boolean } = { arr: [], original: [], - title: '', + titleObject: { + text: '', + hasSubtitle: false, + type: 'article', + }, isDeletion: undefined, isAddition: undefined, } @@ -218,16 +230,19 @@ export const formatAmendingRegBody = ( let pushHtml = '' as HTMLText let isParagraph = false - let isArticleTitle = false + let isSectionTitle = false let isNumberList = false let isLetterList = false - if (element.classList.contains('article__title')) { + const containsAnyTitle = containsAnyTitleClass(element) + if (containsAnyTitle) { const clone = element.cloneNode(true) const textContent = getTextWithSpaces(clone) articleTitle = extractArticleTitleDisplay(textContent) - testGroup.title = articleTitle - isArticleTitle = true + testGroup.titleObject.hasSubtitle = hasSubtitle(textContent) + testGroup.titleObject.type = getArticleTitleType(element) + testGroup.titleObject.text = articleTitle + isSectionTitle = true paragraph = 0 // Reset paragraph count for the new article } else if (element.nodeName.toLowerCase() === 'p') { paragraph++ @@ -249,7 +264,7 @@ export const formatAmendingRegBody = ( const elementType = isLetterList || isNumberList ? 'lidur' - : isArticleTitle + : isSectionTitle ? 'greinTitle' : undefined @@ -275,7 +290,7 @@ export const formatAmendingRegBody = ( // Paragraph was deleted pushHtml = `

${paragraph}. mgr. ${articleTitle} ${regNameDisplay} fellur brott.

` as HTMLText - } else if (isArticleTitle) { + } else if (isSectionTitle) { // Title was deleted pushHtml = `

Fyrirsögn ${articleTitle} ${regNameDisplay} fellur brott.

` as HTMLText @@ -304,7 +319,7 @@ export const formatAmendingRegBody = ( paragraph - 1 }. mgr. ${articleTitle} ${regNameDisplay} kemur ný málsgrein sem orðast svo:

${newText}

` as HTMLText) : (`

Á undan 1. mgr. ${articleTitle} ${regNameDisplay} kemur ný málsgrein svohljóðandi:

${newText}

` as HTMLText) - } else if (isArticleTitle) { + } else if (isSectionTitle) { // Title was added testGroup.original?.push(`

${newText}

` as HTMLText) pushHtml = @@ -337,7 +352,7 @@ export const formatAmendingRegBody = ( // Change detected. Not additon, not deletion. testGroup.isDeletion = false testGroup.isAddition = false - if (isArticleTitle) { + if (isSectionTitle) { // Title was changed pushHtml = `

Fyrirsögn ${articleTitle} ${regNameDisplay} breytist og orðast svo:

${newText}

` as HTMLText @@ -365,7 +380,7 @@ export const formatAmendingRegBody = ( } }) if (testGroup.isDeletion === true) { - const articleTitleNumber = testGroup.title + const articleTitleNumber = testGroup.titleObject.text additionArray.push([ `

${articleTitleNumber} ${regNameDisplay} fellur brott.

` as HTMLText, @@ -376,7 +391,7 @@ export const formatAmendingRegBody = ( if (prevArticle.length > 0) { prevArticleTitle = prevArticle[0]?.innerText } - const articleTitleNumber = testGroup.title + const articleTitleNumber = testGroup.titleObject.text const originalTextArray = testGroup.original?.length ? flatten(testGroup.original) : [] @@ -395,8 +410,13 @@ export const formatAmendingRegBody = ( : '' } + const articleTypeText = getArticleTypeText(testGroup.titleObject.type) additionArray.push([ - `

Á eftir ${prevArticleTitleNumber} ${regNameDisplay} kemur ný grein, ${articleTitleNumber}, ásamt fyrirsögn, svohljóðandi:

${articleDisplayText}` as HTMLText, + `

Á eftir ${prevArticleTitleNumber} ${regNameDisplay} kemur ${articleTypeText}, ${ + articleTitleNumber ? articleTitleNumber + ',' : '' + }${ + testGroup.titleObject.hasSubtitle ? ' ásamt fyrirsögn,' : '' + } svohljóðandi:

${articleDisplayText}` as HTMLText, ]) } else { additionArray.push(testGroup.arr) diff --git a/libs/portals/admin/regulations-admin/src/utils/formatAmendingUtils.ts b/libs/portals/admin/regulations-admin/src/utils/formatAmendingUtils.ts index f67c3b420f81..3108a49dff83 100644 --- a/libs/portals/admin/regulations-admin/src/utils/formatAmendingUtils.ts +++ b/libs/portals/admin/regulations-admin/src/utils/formatAmendingUtils.ts @@ -11,10 +11,8 @@ export const groupElementsByArticleTitleFromDiv = ( Array.from(div.children).forEach((child) => { const element = child as HTMLElement - if ( - element.classList.contains('article__title') || - element.classList.contains('chapter__title') - ) { + const containsAnyTitle = containsAnyTitleClass(element) + if (containsAnyTitle) { if (currentGroup.length > 0) { result.push(currentGroup) } @@ -31,15 +29,53 @@ export const groupElementsByArticleTitleFromDiv = ( return result } +const titleArray = ['article__title', 'subchapter__title', 'chapter__title'] +export type ArticleTitleType = 'article' | 'chapter' | 'subchapter' + +export const containsAnyTitleClass = (element: HTMLElement): boolean => { + return titleArray.some((title) => element.classList.contains(title)) +} + +export const getArticleTypeText = (titleType?: ArticleTitleType): string => { + switch (titleType) { + case 'chapter': + return 'nýr kafli' + case 'subchapter': + return 'nýr undirkafli' + default: + return 'ný grein' + } +} + +export const getArticleTitleType = (element: HTMLElement): ArticleTitleType => { + if (element.classList.contains('subchapter__title')) { + return 'subchapter' + } else if (element.classList.contains('chapter__title')) { + return 'chapter' + } else { + return 'article' + } +} + /** * Extracts article title number (e.g., '1. gr.' or '1. gr. a') from a string, allowing for Icelandic characters. */ +const extractRegex = + /^\d+\. gr\.(?: [\p{L}]\.)?(?= |$)|^(?:\d+|[IVXLCDM]+)\.?\s*Kafli(?=\b| |$)/iu + export const extractArticleTitleDisplay = (title: string): string => { - const grMatch = title.match(/^\d+\. gr\.(?: [\p{L}])?(?= |$)/u) + const grMatch = title.match(extractRegex) const articleTitleDisplay = grMatch ? grMatch[0] : title return articleTitleDisplay } +export const hasSubtitle = (title: string): boolean => { + const grMatch = title.match(extractRegex) + const articleTitleDisplay = grMatch ? grMatch[0] : title + const hasSubText = title.trim() !== articleTitleDisplay.trim() + return hasSubText +} + export const getTextWithSpaces = (element: Node): string => { let result = '' diff --git a/libs/portals/admin/regulations-admin/src/utils/hooks.ts b/libs/portals/admin/regulations-admin/src/utils/hooks.ts index 753a62100bc5..45eed9f313d8 100644 --- a/libs/portals/admin/regulations-admin/src/utils/hooks.ts +++ b/libs/portals/admin/regulations-admin/src/utils/hooks.ts @@ -105,3 +105,46 @@ export const useAffectedRegulations = ( mentionedOptions, } } + +export const usePristineRegulations = () => { + const P_REG_KEY = 'PRISTINE_REGULATIONS' + + const [pristineRegulations, setPristineRegulations] = useState( + () => { + const storedRegulations = sessionStorage.getItem(P_REG_KEY) + if (!storedRegulations) return [] + + try { + return JSON.parse(storedRegulations) + } catch (e) { + console.error('Failed to parse pristine regulations:', e) + return [] + } + }, + ) + + const addPristineRegulation = (regId: string) => { + if (!pristineRegulations.includes(regId)) { + const updatedRegulations = [...pristineRegulations, regId] + setPristineRegulations(updatedRegulations) + sessionStorage.setItem(P_REG_KEY, JSON.stringify(updatedRegulations)) + } + } + + const removePristineRegulation = (regId: string) => { + const updatedRegulations = pristineRegulations.filter( + (id: string) => id !== regId, + ) + setPristineRegulations(updatedRegulations) + sessionStorage.setItem(P_REG_KEY, JSON.stringify(updatedRegulations)) + } + + const isPristineRegulation = (regId: string) => + pristineRegulations.includes(regId) + + return { + addPristineRegulation, + removePristineRegulation, + isPristineRegulation, + } +} diff --git a/libs/portals/admin/service-desk/src/screens/Companies/Companies.action.ts b/libs/portals/admin/service-desk/src/screens/Companies/Companies.action.ts index 6110e892e1b6..106e56adce3c 100644 --- a/libs/portals/admin/service-desk/src/screens/Companies/Companies.action.ts +++ b/libs/portals/admin/service-desk/src/screens/Companies/Companies.action.ts @@ -68,20 +68,6 @@ export const CompaniesAction: WrappedActionFn = throw res.error } - const companies = res.data?.companyRegistryCompanies?.data - - // Redirect to Procurers screen if only one company is found - if (companies?.length === 1) { - return redirect( - replaceParams({ - href: ServiceDeskPaths.Company, - params: { - nationalId: companies[0].nationalId, - }, - }), - ) - } - return { errors: null, data: res.data.companyRegistryCompanies, diff --git a/libs/portals/admin/service-desk/src/screens/Companies/Companies.tsx b/libs/portals/admin/service-desk/src/screens/Companies/Companies.tsx index 2955edb6e8fe..e15a2c5115f6 100644 --- a/libs/portals/admin/service-desk/src/screens/Companies/Companies.tsx +++ b/libs/portals/admin/service-desk/src/screens/Companies/Companies.tsx @@ -79,14 +79,12 @@ const Companies = () => { buttonProps={{ type: 'submit', disabled: !searchInput, - onClick: (e) => { - e.preventDefault() + onClick: () => { if (searchInput) { setSearchParams((params) => { params.set('q', searchInput) return params }) - submit(formRef.current) } }, }} diff --git a/libs/portals/admin/service-desk/src/screens/Users/Users.action.ts b/libs/portals/admin/service-desk/src/screens/Users/Users.action.ts index 4d723cad3321..13454e204aaa 100644 --- a/libs/portals/admin/service-desk/src/screens/Users/Users.action.ts +++ b/libs/portals/admin/service-desk/src/screens/Users/Users.action.ts @@ -78,22 +78,6 @@ export const UsersAction: WrappedActionFn = throw res.error } - const { data: respData, totalCount } = res.data.UserProfileAdminProfiles - - if (totalCount === 1) { - return redirect( - replaceParams({ - href: ServiceDeskPaths.User, - params: { - nationalId: (await maskString( - respData[0].nationalId, - userInfo.profile.nationalId, - )) as string, - }, - }), - ) - } - return { data: res.data.UserProfileAdminProfiles, errors: null, diff --git a/libs/portals/admin/service-desk/src/screens/Users/Users.tsx b/libs/portals/admin/service-desk/src/screens/Users/Users.tsx index 694c3357a959..642110a7ad76 100644 --- a/libs/portals/admin/service-desk/src/screens/Users/Users.tsx +++ b/libs/portals/admin/service-desk/src/screens/Users/Users.tsx @@ -87,14 +87,12 @@ const Users = () => { buttonProps={{ type: 'submit', disabled: searchInput.length === 0, - onClick: (e) => { - e.preventDefault() + onClick: () => { if (searchInput) { setSearchParams((params) => { params.set('q', searchInput) return params }) - submit(formRef.current) } }, }} diff --git a/libs/portals/shared-modules/delegations/src/components/delegations/DelegationViewModal.tsx b/libs/portals/shared-modules/delegations/src/components/delegations/DelegationViewModal.tsx index 277823485167..aaa02e0972ae 100644 --- a/libs/portals/shared-modules/delegations/src/components/delegations/DelegationViewModal.tsx +++ b/libs/portals/shared-modules/delegations/src/components/delegations/DelegationViewModal.tsx @@ -1,6 +1,5 @@ -import { useEffect } from 'react' -import { useAuth } from '@island.is/auth/react' import { Box } from '@island.is/island-ui/core' +import { useEffect } from 'react' import { useLocale } from '@island.is/localization' import { formatNationalId } from '@island.is/portals/core' @@ -9,10 +8,11 @@ import { IdentityCard } from '../IdentityCard/IdentityCard' import { AccessListContainer } from '../access/AccessList/AccessListContainer/AccessListContainer' import { useAuthScopeTreeLazyQuery } from '../access/AccessList/AccessListContainer/AccessListContainer.generated' -import { m } from '../../lib/messages' -import format from 'date-fns/format' import { AuthDelegationScope, AuthDelegationType } from '@island.is/api/schema' +import { useUserInfo } from '@island.is/react-spa/bff' +import format from 'date-fns/format' import isValid from 'date-fns/isValid' +import { m } from '../../lib/messages' type DelegationViewModalProps = { delegation?: { @@ -45,7 +45,7 @@ export const DelegationViewModal = ({ ...rest }: DelegationViewModalProps) => { const { formatMessage, lang } = useLocale() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const isOutgoing = direction === 'outgoing' const [getAuthScopeTree, { data: scopeTreeData, loading: scopeTreeLoading }] = useAuthScopeTreeLazyQuery()