diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/ApiDelegationInput.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/ApiDelegationInput.cs new file mode 100644 index 000000000..df307087d --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/ApiDelegationInput.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Altinn.AccessManagement.UI.Core.Models +{ + /// + /// Model for performing a delegation of one or more rights to one or more recipients. + /// + public class ApiDelegationInput + { + /// + /// Gets or sets the list of organization numbers. This field is required. + /// + public List OrgNumbers { get; set; } + + /// + /// Gets or sets the list of API identifiers. This field is required. + /// + public List ApiIdentifiers { get; set; } + } +} \ No newline at end of file diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/ApiDelegationOutput.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/ApiDelegationOutput.cs new file mode 100644 index 000000000..0b8769814 --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/ApiDelegationOutput.cs @@ -0,0 +1,25 @@ +using Altinn.AccessManagement.UI.Core.Models.SingleRight; + +namespace Altinn.AccessManagement.UI.Core.Models +{ + /// + /// Response model for the result of a api-delegation to a recipient. + /// + public class ApiDelegationOutput + { + /// + /// Gets or sets the organization identifier. + /// + public string OrgNumber { get; set; } + + /// + /// Gets or sets the API identifier. + /// + public string ApiId { get; set; } + + /// + /// Gets or sets a value indicating whether the operation was successful. + /// + public bool Success { get; set; } + } +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/DelegationOutput.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/DelegationOutput.cs index 3d917f229..f1e280e98 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/DelegationOutput.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Models/Delegation/DelegationOutput.cs @@ -5,7 +5,7 @@ namespace Altinn.AccessManagement.UI.Core.Models /// /// Response model for the result of a delegation of one or more rights to a recipient. /// - public class DelegationOutput + public class DelegationOutput { /// /// Attribute id and value for the party delegating the rights diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/APIDelegationService.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/APIDelegationService.cs index e6e202f7c..eea1c5b3f 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/APIDelegationService.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/APIDelegationService.cs @@ -1,4 +1,5 @@ -using Altinn.AccessManagement.UI.Core.ClientInterfaces; +using System.Text.Json; +using Altinn.AccessManagement.UI.Core.ClientInterfaces; using Altinn.AccessManagement.UI.Core.Models; using Altinn.AccessManagement.UI.Core.Models.Delegation; using Altinn.AccessManagement.UI.Core.Models.Delegation.Frontend; @@ -16,6 +17,11 @@ public class APIDelegationService : IAPIDelegationService private readonly IAccessManagementClient _maskinportenSchemaClient; private readonly IResourceService _resourceService; + private readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + /// /// Initializes a new instance of the class. /// @@ -61,6 +67,54 @@ public async Task CreateMaskinportenScopeDelegation(string return await _maskinportenSchemaClient.CreateMaskinportenScopeDelegation(party, delegation); } + /// + public async Task> BatchCreateMaskinportenScopeDelegation(string party, ApiDelegationInput delegation) + { + List delegationOutputs = new List(); + + foreach (var org in delegation.OrgNumbers) + { + foreach (var api in delegation.ApiIdentifiers) + { + var delegationObject = new DelegationInput + { + To = new List { new IdValuePair { Id = "urn:altinn:organizationnumber", Value = org } }, + Rights = new List + { + new Right + { + Resource = new List { new IdValuePair { Id = "urn:altinn:resource", Value = api } } + } + } + }; + try + { + var response = await _maskinportenSchemaClient.CreateMaskinportenScopeDelegation(party, delegationObject); + string responseContent = await response.Content.ReadAsStringAsync(); + + delegationOutputs.Add(new ApiDelegationOutput() + { + OrgNumber = org, + ApiId = api, + Success = response.StatusCode == System.Net.HttpStatusCode.Created + }); + } + catch (Exception e) + { + System.Diagnostics.Debug.WriteLine(e.Message); + delegationOutputs.Add(new ApiDelegationOutput() + { + OrgNumber = org, + ApiId = api, + Success = false, + }); + } + } + } + + return delegationOutputs; + } + /// public async Task> DelegationCheck(string partyId, Right request) { diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IAPIDelegationService.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IAPIDelegationService.cs index 10cd97f3d..57d8e5c79 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IAPIDelegationService.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IAPIDelegationService.cs @@ -49,6 +49,14 @@ public interface IAPIDelegationService /// public Task CreateMaskinportenScopeDelegation(string party, DelegationInput delegation); + /// + /// Delegates maskinporten scope to organizations, on behalf of a sinlge party. This endpoint enables both single delegations and batches of one or more maskinporten scopes to one or more organizations + /// + /// party + /// delegation to be performed + /// + public Task> BatchCreateMaskinportenScopeDelegation(string party, ApiDelegationInput delegation); + /// /// Endpoint for performing a check if the user can delegate a maskinporten schema service to a specified reportee. /// diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/MaskinportenSchema/Delegation/batch-delegation-mixed-response.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/MaskinportenSchema/Delegation/batch-delegation-mixed-response.json new file mode 100644 index 000000000..fb75ef30b --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/MaskinportenSchema/Delegation/batch-delegation-mixed-response.json @@ -0,0 +1,22 @@ +[ + { + "orgNumber": "810418362", + "apiId": "appid-402", + "success": true + }, + { + "orgNumber": "123456789", + "apiId": "appid-402", + "success": true + }, + { + "orgNumber": "123456789", + "apiId": "invalid_org_id", + "success": false + }, + { + "orgNumber": "810418362", + "apiId": "invalid_org_id", + "success": false + } +] \ No newline at end of file diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/MaskinportenSchema/Delegation/batch-delegation-response.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/MaskinportenSchema/Delegation/batch-delegation-response.json new file mode 100644 index 000000000..0d2618f8b --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/MaskinportenSchema/Delegation/batch-delegation-response.json @@ -0,0 +1,22 @@ +[ + { + "orgNumber": "810418362", + "apiId": "appid-402", + "success": true + }, + { + "orgNumber": "810418362", + "apiId": "appid-400", + "success": true + }, + { + "orgNumber": "810418532", + "apiId": "appid-402", + "success": true + }, + { + "orgNumber": "810418532", + "apiId": "appid-400", + "success": true + } +] \ No newline at end of file diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/APIDelegationControllerTest.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/APIDelegationControllerTest.cs index 9ed0e9f59..8993a255a 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/APIDelegationControllerTest.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/APIDelegationControllerTest.cs @@ -206,6 +206,59 @@ public async Task PostMaskinportenSchemaDelegation_ExternalExceptionHandled() Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); } + + /// + /// Tests the batch delegation of Maskinporten schema resources to various organizations by an authenticated user (DAGL) for multiple reportee parties. + /// Verifies that a successful delegation returns a 200 OK status code with a response body containing the expected delegated rights for all parties. + /// + [Fact] + public async Task PostBatchMaskinportenSchemaDelegation_DAGL_Success() + { + + string fromParty = "50005545"; + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetToken(20000490, 50002598)); + + List expectedResponse = GetExpectedBatchResponse("Delegation", "batch-delegation-response"); + StreamContent requestContent = GetRequestContent("Delegation", "Input_Batch"); + + // Act + HttpResponseMessage response = await _client.PostAsync($"accessmanagement/api/v1/apidelegation/{fromParty}/offered/batch", requestContent); + string responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List actualResponse = JsonSerializer.Deserialize>(responseContent, options); + AssertionUtil.AssertEqual(expectedResponse, actualResponse); + } + + /// + /// Tests the partial success of batch delegation of resources to multiple organizations by an authenticated user, where some delegations succeed while others fail. + /// Verifies that the response appropriately reflects the mixed outcome, indicating both the successfully delegated rights and the failures, along with corresponding status codes for each. + /// + [Fact] + public async Task PostBatchMaskinportenSchemaDelegation_DAGL_PartialSuccess() + { + + string fromParty = "50005545"; + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetToken(20000490, 50002598)); + + List expectedResponse = GetExpectedBatchResponse("Delegation", "batch-delegation-mixed-response"); + StreamContent requestContent = GetRequestContent("Delegation", "Input_Batch_Invalid"); + + // Act + HttpResponseMessage response = await _client.PostAsync($"accessmanagement/api/v1/apidelegation/{fromParty}/offered/batch", requestContent); + string responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List actualResponse = JsonSerializer.Deserialize>(responseContent, options); + + AssertionUtil.AssertEqual(expectedResponse, actualResponse); + } + + /// /// Test case: RevokeOfferedMaskinportenScopeDelegation for the reportee party 50004222 of the nav_aa_distribution /// maskinporten schema resource from the resource registry, @@ -256,7 +309,7 @@ public async Task DelegationCheck_HasDelegableRights() // Arrange int reporteePartyId = 50076002; string resourceId = "default"; - + string folderPath = Path.Combine(unitTestFolder, "Data", "ExpectedResults", "MaskinportenSchema", "DelegationCheck", "scope-access-schema"); string fullPath = Path.Combine(folderPath, resourceId + ".json"); List expectedResponse = Util.GetMockData>(fullPath); @@ -267,11 +320,11 @@ public async Task DelegationCheck_HasDelegableRights() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + List actualResponse = JsonSerializer.Deserialize>(responseContent, options); AssertionUtil.AssertCollections(expectedResponse, actualResponse, AssertionUtil.AssertEqual); } - + /// /// Test case: Tests if response has insufficient access level /// Expected: Resource has insufficient access level @@ -294,7 +347,7 @@ public async Task DelegationCheck_InsufficientAccessLevel() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + List actualResponse = JsonSerializer.Deserialize>(responseContent, options); AssertionUtil.AssertCollections(expectedResponse, actualResponse, AssertionUtil.AssertEqual); } @@ -316,13 +369,28 @@ private static StreamContent GetRequestContent(string operation, string inputFil return content; } - private static DelegationOutput GetExpectedResponse(string operation, string resourceId) + private static DelegationOutput GetExpectedResponse(string operation, string fileName) { - string responsePath = $"Data/MaskinportenSchema/{operation}/{resourceId}.json"; + string responsePath = $"Data/MaskinportenSchema/{operation}/{fileName}.json"; string content = File.ReadAllText(responsePath); return (DelegationOutput)JsonSerializer.Deserialize(content, typeof(DelegationOutput), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } + class BatchDelegationOutput + { + public List result { get; set; } + } + + + private static List GetExpectedBatchResponse(string operation, string resourceId) + { + string responsePath = $"Data/MaskinportenSchema/{operation}/{resourceId}.json"; + string content = File.ReadAllText(responsePath); + + var res = (List)JsonSerializer.Deserialize(content, typeof(List), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return res; + } + private static List GetExpectedInboundDelegationsForParty(int covererdByPartyId) { List inboundDelegations = new List(); diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Data/RequestInputs/MaskinportenSchema/Delegation/Input_Batch.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Data/RequestInputs/MaskinportenSchema/Delegation/Input_Batch.json new file mode 100644 index 000000000..ceca11fd1 --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Data/RequestInputs/MaskinportenSchema/Delegation/Input_Batch.json @@ -0,0 +1,4 @@ +{ + "apiIdentifiers": ["appid-402", "appid-400"], + "orgNumbers": ["810418362", "810418532"] +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Data/RequestInputs/MaskinportenSchema/Delegation/Input_Batch_Invalid.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Data/RequestInputs/MaskinportenSchema/Delegation/Input_Batch_Invalid.json new file mode 100644 index 000000000..da4cf1e67 --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Data/RequestInputs/MaskinportenSchema/Delegation/Input_Batch_Invalid.json @@ -0,0 +1,4 @@ +{ + "apiIdentifiers": ["appid-402", "invalid_org_id"], + "orgNumbers": ["810418362", "123456789"] +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Utils/AssertionUtil.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Utils/AssertionUtil.cs index bc1ce6a11..15da4de38 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Utils/AssertionUtil.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Utils/AssertionUtil.cs @@ -189,7 +189,7 @@ public static void AssertEqual(List expected, List a Assert.Equal(expected[i].ContactPage, actual[i].ContactPage); } } - + /// /// Assert that two Lists of have the same property in the same positions. /// @@ -269,5 +269,18 @@ private static void AssertEqual(IdValuePair expected, IdValuePair actual) Assert.Equal(expected.Id, actual.Id); Assert.Equal(expected.Value, actual.Value); } + + public static void AssertEqual(List expected, List actual) + { + Assert.NotNull(actual); + Assert.NotNull(expected); + + Assert.Equal(expected.Count, actual.Count); + foreach (var item in expected) + { + var a = actual.FindAll(c => c.OrgNumber == item.OrgNumber && c.ApiId == item.ApiId && c.Success == item.Success); + Assert.Single(a); + } + } } } diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/APIDelegationController.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/APIDelegationController.cs index 4fd4ddbac..e56cf5027 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/APIDelegationController.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/APIDelegationController.cs @@ -121,7 +121,7 @@ public async Task RevokeReceivedAPIDelegation([FromRoute] string p { return NoContent(); } - + string responseContent = await response.Content.ReadAsStringAsync(); return new ObjectResult(ProblemDetailsFactory.CreateProblemDetails(HttpContext, (int?)response.StatusCode, "Unexpected HttpStatus response", detail: responseContent)); } @@ -150,7 +150,7 @@ public async Task RevokeOfferedAPIDelegation([FromRoute] string pa { return NoContent(); } - + if (response.StatusCode == HttpStatusCode.BadRequest) { string responseContent = await response.Content.ReadAsStringAsync(); @@ -209,6 +209,27 @@ public async Task> CreateMaskinportenDelegation([ } } + /// + /// Endpoint for delegating one or more maskinporten schema resource from the reportee party to one or more third party organizations + /// + /// Bad Request + /// Internal Server Error + [HttpPost] + [Authorize] + [Route("{party}/offered/batch")] + public async Task>> CreateMaskinportenDelegationBatch([FromRoute] string party, [FromBody] ApiDelegationInput delegation) + { + try + { + var response = await _apiDelegationService.BatchCreateMaskinportenScopeDelegation(party, delegation); + return Ok(response); + } + catch + { + return new ObjectResult(ProblemDetailsFactory.CreateProblemDetails(HttpContext)); + } + } + /// /// Endpoint for performing a check if the user can delegate a maskinporten schema service to a specified reportee. /// diff --git a/src/dataObjects/dtos/resourceDelegation.tsx b/src/dataObjects/dtos/resourceDelegation.tsx index f7ca644ab..89c194dbc 100644 --- a/src/dataObjects/dtos/resourceDelegation.tsx +++ b/src/dataObjects/dtos/resourceDelegation.tsx @@ -41,3 +41,17 @@ type details = { description: string; parameters: IdValuePair; }; + +export type DelegationResult = { + from: IdValuePair; + to: IdValuePair; + delegationResult: DelegationAccessResult[]; +}; + +export type ApiDelegationResult = { + orgNumber: string; + orgName: string; + apiId: string; + apiName: string; + success: boolean; +}; diff --git a/src/features/apiDelegation/components/OverviewPageContent/OverviewPageContent.tsx b/src/features/apiDelegation/components/OverviewPageContent/OverviewPageContent.tsx index dd93a86a1..2255646de 100644 --- a/src/features/apiDelegation/components/OverviewPageContent/OverviewPageContent.tsx +++ b/src/features/apiDelegation/components/OverviewPageContent/OverviewPageContent.tsx @@ -7,7 +7,6 @@ import * as React from 'react'; import { PlusIcon, PencilIcon, XMarkOctagonIcon } from '@navikt/aksel-icons'; import { useAppDispatch, useAppSelector } from '@/rtk/app/hooks'; -import { resetDelegationRequests } from '@/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice'; import { resetState } from '@/rtk/features/apiDelegation/apiDelegationSlice'; import { fetchOverviewOrgsOffered, @@ -62,7 +61,6 @@ export const OverviewPageContent = ({ } handleSaveDisabled(); dispatch(resetState()); - dispatch(resetDelegationRequests()); dispatch(resetChosenApis()); }, [overviewOrgs, error]); diff --git a/src/features/apiDelegation/offered/ChooseApiPage/ChooseApiPage.module.css b/src/features/apiDelegation/offered/ChooseApiPage/ChooseApiPage.module.css index 4fc7d6285..888bb67e5 100644 --- a/src/features/apiDelegation/offered/ChooseApiPage/ChooseApiPage.module.css +++ b/src/features/apiDelegation/offered/ChooseApiPage/ChooseApiPage.module.css @@ -19,13 +19,11 @@ } .actionBarWrapper { - display: flex; - flex-direction: column; - gap: 5px; margin-top: 5px; margin-bottom: 5px; display: flex; flex-direction: column; + gap: 5px; } .chooseApiSecondHeader { diff --git a/src/features/apiDelegation/offered/ChooseOrgPage/ChooseOrgPage.module.css b/src/features/apiDelegation/offered/ChooseOrgPage/ChooseOrgPage.module.css index c8e4c1d45..8f0b3f0e8 100644 --- a/src/features/apiDelegation/offered/ChooseOrgPage/ChooseOrgPage.module.css +++ b/src/features/apiDelegation/offered/ChooseOrgPage/ChooseOrgPage.module.css @@ -68,6 +68,14 @@ gap: 15px; } +.actionBarWrapper { + gap: 5px; + margin-top: 5px; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + @media only screen and (max-width: 768px) { .pageContentContainer { display: grid; diff --git a/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.module.css b/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.module.css index af6c239c2..febddf698 100644 --- a/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.module.css +++ b/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.module.css @@ -55,3 +55,7 @@ margin-top: 15px; margin-bottom: 30px; } + +.list { + margin-bottom: 40px; +} diff --git a/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.tsx b/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.tsx index 45e84b54d..4caae81a1 100644 --- a/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.tsx +++ b/src/features/apiDelegation/offered/ConfirmationPage/ConfirmationPage.tsx @@ -1,125 +1,125 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import * as React from 'react'; -import type { Key } from 'react'; -import { useEffect, useState } from 'react'; -import { Buldings3Icon, CogIcon } from '@navikt/aksel-icons'; -import { Button, Heading, Paragraph, Spinner } from '@digdir/designsystemet-react'; +import { Alert, Button, Heading, Paragraph, Spinner } from '@digdir/designsystemet-react'; import { useAppDispatch, useAppSelector } from '@/rtk/app/hooks'; import { ApiDelegationPath } from '@/routes/paths'; import ApiIcon from '@/assets/Api.svg?react'; import { - CompactDeletableListItem, GroupElements, Page, PageContainer, PageContent, PageHeader, RestartPrompter, - BorderedList, } from '@/components'; -import type { - ApiDelegation, - DelegationRequest, -} from '@/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice'; -import { - postApiDelegation, - setBatchPostSize, -} from '@/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice'; -import type { DelegableApi } from '@/rtk/features/apiDelegation/delegableApi/delegableApiSlice'; -import { softRemoveApi } from '@/rtk/features/apiDelegation/delegableApi/delegableApiSlice'; -import type { Organization } from '@/rtk/features/lookup/lookupApi'; -import { removeOrg } from '@/rtk/features/apiDelegation/apiDelegationSlice'; import { useMediaQuery } from '@/resources/hooks'; import { useDocumentTitle } from '@/resources/hooks/useDocumentTitle'; - +import { setLoading as setOveviewToReload } from '@/rtk/features/apiDelegation/overviewOrg/overviewOrgSlice'; +import { + BatchApiDelegationRequest, + usePostApiDelegationMutation, +} from '@/rtk/features/apiDelegation/apiDelegationApi'; +import { getCookie } from '@/resources/Cookie/CookieMethods'; import classes from './ConfirmationPage.module.css'; +import { ListTextColor } from '@/components/CompactDeletableListItem/CompactDeletableListItem'; +import { DelegableApiList, DelegableOrgList, DelegationReceiptList } from './DelegationLists'; + export const ConfirmationPage = () => { const chosenApis = useAppSelector((state) => state.delegableApi.chosenApis); const chosenOrgs = useAppSelector((state) => state.apiDelegation.chosenOrgs); - const loading = useAppSelector((state) => state.delegationRequest.loading); - const [isProcessingDelegations, setIsProcessingDelegations] = useState(false); + const isSm = useMediaQuery('(max-width: 768px)'); const { t } = useTranslation(); + + useDocumentTitle(t('api_delegation.delegate_page_title')); + + const partyId = getCookie('AltinnPartyId'); const navigate = useNavigate(); const dispatch = useAppDispatch(); - useDocumentTitle(t('api_delegation.delegate_page_title')); - useEffect(() => { - if (!loading) { - navigate('/' + ApiDelegationPath.OfferedApiDelegations + '/' + ApiDelegationPath.Receipt); - } - }, [loading]); + const [postApiDelegation, { data, isLoading, isError }] = usePostApiDelegationMutation(); - const handleConfirm = () => { - setIsProcessingDelegations(true); - const batchSize = chosenOrgs.length * chosenApis.length; - dispatch(setBatchPostSize(batchSize)); - for (const org of chosenOrgs) { - for (const api of chosenApis) { - const request: DelegationRequest = { - apiIdentifier: api.identifier, - apiName: api.apiName, - orgName: org.name, - orgNr: org.orgNumber, - }; - void dispatch(postApiDelegation(request)); - } - } + const successfulApiDelegations = React.useMemo( + () => data?.filter((d) => d.success) || [], + [data], + ); + const failedApiDelegations = React.useMemo(() => data?.filter((d) => !d.success) || [], [data]); + + const handleConfirm = async () => { + const request: BatchApiDelegationRequest = { + partyId, + apis: chosenApis, + orgs: chosenOrgs, + }; + postApiDelegation(request); }; - const delegableApiList = () => { - return ( -
- - {chosenApis?.map((api: DelegableApi | ApiDelegation, index: Key) => ( - } - removeCallback={chosenApis.length > 1 ? () => dispatch(softRemoveApi(api)) : null} - leftText={api.apiName} - middleText={api.orgName} - > - ))} - -
- ); + const navigateToOverview = () => { + dispatch(setOveviewToReload()); + navigate('/' + ApiDelegationPath.OfferedApiDelegations + '/' + ApiDelegationPath.Overview); }; - const delegableOrgList = () => { + const delegationRecieptContent = () => { return ( -
- - {chosenOrgs?.map((org: Organization, index: Key | null | undefined) => ( - } - removeCallback={chosenOrgs.length > 1 ? () => dispatch(removeOrg(org)) : null} - leftText={org.name} - middleText={t('common.org_nr') + ' ' + org.orgNumber} - > - ))} - -
+ <> + {failedApiDelegations.length > 0 && ( + <> + + {t('api_delegation.failed_delegations')} + + + + )} + {successfulApiDelegations.length > 0 && ( + <> + + {t('api_delegation.succesful_delegations')} + + + + )} + + {successfulApiDelegations.length === 0 + ? t('api_delegation.receipt_page_failed_text') + : t('api_delegation.receipt_page_bottom_text')} + + + ); }; - const showTopSection = () => { - return chosenApis !== null && chosenApis !== undefined && chosenApis?.length > 0; - }; - - const showBottomSection = () => { - return chosenOrgs !== null && chosenOrgs !== undefined && chosenOrgs?.length > 0; - }; - - const showErrorPanel = () => { - return !showTopSection() && !showBottomSection(); + const shouldShowErrorPanel = () => { + return (!chosenApis || chosenApis.length === 0) && (!chosenOrgs || chosenOrgs.length === 0); }; const delegationContent = () => { - return ( + return data && data.length > 0 ? ( + delegationRecieptContent() + ) : ( <> { > {t('api_delegation.confirmation_page_content_top_text')} - {delegableApiList()} + {t('api_delegation.confirmation_page_content_second_text')} - {delegableOrgList()} + {t('api_delegation.confirmation_page_content_bottom_text')} @@ -156,7 +156,7 @@ export const ConfirmationPage = () => { color={'success'} fullWidth={isSm} > - {isProcessingDelegations && ( + {isLoading && ( {
0 + ? 'danger' + : successfulApiDelegations.length > 0 + ? 'success' + : 'dark' + } size={isSm ? 'small' : 'medium'} > }>{t('api_delegation.give_access_to_new_api')} - {showErrorPanel() ? ( + {shouldShowErrorPanel() ? ( { ) : ( delegationContent() )} + {isError && ( + + {t('common.general_error_title')} + {`${t('common.general_error_paragraph')}`} + + )} diff --git a/src/features/apiDelegation/offered/ConfirmationPage/DelegationLists.tsx b/src/features/apiDelegation/offered/ConfirmationPage/DelegationLists.tsx new file mode 100644 index 000000000..3141e4630 --- /dev/null +++ b/src/features/apiDelegation/offered/ConfirmationPage/DelegationLists.tsx @@ -0,0 +1,75 @@ +import { BorderedList, CompactDeletableListItem } from '@/components'; +import React, { Key } from 'react'; +import classes from './ConfirmationPage.module.css'; +import { ListTextColor } from '@/components/CompactDeletableListItem/CompactDeletableListItem'; +import { ApiDelegationResult } from '@/dataObjects/dtos/resourceDelegation'; +import { removeOrg } from '@/rtk/features/apiDelegation/apiDelegationSlice'; +import { useAppDispatch, useAppSelector } from '@/rtk/app/hooks'; +import { + DelegableApi, + ApiDelegation, + softRemoveApi, +} from '@/rtk/features/apiDelegation/delegableApi/delegableApiSlice'; +import { Organization } from '@/rtk/features/lookup/lookupApi'; +import { CogIcon, Buldings3Icon } from '@navikt/aksel-icons'; +import { t } from 'i18next'; + +interface DelegationReceiptListProps { + items: ApiDelegationResult[]; + contentColor?: ListTextColor; +} + +export const DelegationReceiptList = ({ items, contentColor }: DelegationReceiptListProps) => ( + + {items?.map((item, index) => ( + + ))} + +); + +export const DelegableApiList = () => { + const chosenApis = useAppSelector((state) => state.delegableApi.chosenApis); + const dispatch = useAppDispatch(); + + return ( +
+ + {chosenApis?.map((api: DelegableApi | ApiDelegation, index: Key) => ( + } + removeCallback={chosenApis.length > 1 ? () => dispatch(softRemoveApi(api)) : null} + leftText={api.apiName} + middleText={api.orgName} + > + ))} + +
+ ); +}; + +export const DelegableOrgList = () => { + const chosenOrgs = useAppSelector((state) => state.apiDelegation.chosenOrgs); + const dispatch = useAppDispatch(); + + return ( +
+ + {chosenOrgs?.map((org: Organization, index: Key | null | undefined) => ( + } + removeCallback={chosenOrgs.length > 1 ? () => dispatch(removeOrg(org)) : null} + leftText={org.name} + middleText={t('common.org_nr') + ' ' + org.orgNumber} + > + ))} + +
+ ); +}; diff --git a/src/features/apiDelegation/offered/ReceiptPage/ReceiptPage.module.css b/src/features/apiDelegation/offered/ReceiptPage/ReceiptPage.module.css deleted file mode 100644 index 769e39bcc..000000000 --- a/src/features/apiDelegation/offered/ReceiptPage/ReceiptPage.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.list { - margin-bottom: 40px !important; -} diff --git a/src/features/apiDelegation/offered/ReceiptPage/ReceiptPage.tsx b/src/features/apiDelegation/offered/ReceiptPage/ReceiptPage.tsx deleted file mode 100644 index 9601fb494..000000000 --- a/src/features/apiDelegation/offered/ReceiptPage/ReceiptPage.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { Key } from 'react'; -import * as React from 'react'; -import { Button, Heading, Paragraph } from '@digdir/designsystemet-react'; -import { useNavigate } from 'react-router-dom'; - -import { useAppDispatch, useAppSelector } from '@/rtk/app/hooks'; -import { ApiDelegationPath } from '@/routes/paths'; -import ApiIcon from '@/assets/Api.svg?react'; -import { setLoading as setOveviewToReload } from '@/rtk/features/apiDelegation/overviewOrg/overviewOrgSlice'; -import { - PageContainer, - Page, - PageHeader, - PageContent, - CompactDeletableListItem, - RestartPrompter, - BorderedList, -} from '@/components'; -import { ListTextColor } from '@/components/CompactDeletableListItem/CompactDeletableListItem'; -import type { ApiDelegation } from '@/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice'; -import { useMediaQuery } from '@/resources/hooks'; -import { useDocumentTitle } from '@/resources/hooks/useDocumentTitle'; - -import classes from './ReceiptPage.module.css'; - -export const ReceiptPage = () => { - const failedApiDelegations = useAppSelector( - (state) => state.delegationRequest.failedApiDelegations, - ); - const successfulApiDelegations = useAppSelector( - (state) => state.delegationRequest.succesfulApiDelegations, - ); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const isSm = useMediaQuery('(max-width: 768px)'); - const navigate = useNavigate(); - useDocumentTitle(t('api_delegation.delegate_page_title')); - - const failedDelegationContent = () => { - return ( - <> - - {t('api_delegation.failed_delegations')} - - - {failedApiDelegations?.map( - (apiDelegation: ApiDelegation, index: Key | null | undefined) => ( - - ), - )} - - - ); - }; - - const successfulDelegationsContent = () => { - return ( - <> - - {t('api_delegation.succesful_delegations')} - - - {successfulApiDelegations?.map( - (apiDelegation: ApiDelegation, index: Key | null | undefined) => ( - - ), - )} - - - ); - }; - - const delegatedContent = () => { - return ( - <> - {showTopSection() && failedDelegationContent()} - {showBottomSection() && successfulDelegationsContent()} - - ); - }; - - const showTopSection = () => { - return ( - failedApiDelegations !== null && - failedApiDelegations !== undefined && - failedApiDelegations?.length > 0 - ); - }; - - const showBottomSection = () => { - return ( - successfulApiDelegations !== null && - successfulApiDelegations !== undefined && - successfulApiDelegations?.length > 0 - ); - }; - - const showErrorAlert = () => { - return !showTopSection() && !showBottomSection(); - }; - - const navigateToOverview = () => { - dispatch(setOveviewToReload()); - navigate('/' + ApiDelegationPath.OfferedApiDelegations + '/' + ApiDelegationPath.Overview); - }; - - return ( - - - }>{t('api_delegation.give_access_to_new_api')} - - {showErrorAlert() ? ( - - ) : ( -
- {delegatedContent()} - - {successfulApiDelegations.length === 0 - ? t('api_delegation.receipt_page_failed_text') - : t('api_delegation.receipt_page_bottom_text')} - -
- )} - -
-
-
- ); -}; diff --git a/src/features/apiDelegation/offered/ReceiptPage/index.ts b/src/features/apiDelegation/offered/ReceiptPage/index.ts deleted file mode 100644 index ab707e96d..000000000 --- a/src/features/apiDelegation/offered/ReceiptPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ReceiptPage } from './ReceiptPage'; diff --git a/src/routes/Router/Router.tsx b/src/routes/Router/Router.tsx index 8ef72ad7b..637da1566 100644 --- a/src/routes/Router/Router.tsx +++ b/src/routes/Router/Router.tsx @@ -5,7 +5,6 @@ import { ChooseApiPage } from '@/features/apiDelegation/offered/ChooseApiPage'; import { OverviewPage as OfferedOverviewPage } from '@/features/apiDelegation/offered/OverviewPage'; import { OverviewPage as ReceivedOverviewPage } from '@/features/apiDelegation/received/OverviewPage'; import { ChooseOrgPage } from '@/features/apiDelegation/offered/ChooseOrgPage'; -import { ReceiptPage } from '@/features/apiDelegation/offered/ReceiptPage'; import { ConfirmationPage } from '@/features/apiDelegation/offered/ConfirmationPage'; import { ErrorPage } from '@/sites/ErrorPage'; import { ChooseServicePage as DelegateChooseServicePage } from '@/features/singleRight/delegate/ChooseServicePage/ChooseServicePage'; @@ -43,10 +42,6 @@ export const Router = createBrowserRouter( path={ApiDelegationPath.Confirmation} element={} /> - } - /> ({ + query: ({ partyId, apis, orgs }) => ({ + url: `apidelegation/${partyId}/offered/batch`, + method: 'POST', + body: JSON.stringify({ + apiIdentifiers: apis.map((api) => api.identifier), + orgNumbers: orgs.map((org) => org.orgNumber), + }), + }), + transformResponse: (response: ApiDelegationResult[], _meta, args) => { + return response.map((d) => { + return { + orgNumber: d.orgNumber, + orgName: args.orgs.find((org) => org.orgNumber === d.orgNumber)?.name || '', + apiId: d.apiId, + apiName: args.apis.find((api) => api.identifier === d.apiId)?.apiName || '', + success: d.success, + }; + }); + }, + transformErrorResponse: (response: { status: string | number }) => { + return response.status; + }, + }), }), }); -export const { useDelegationCheckMutation, useSearchQuery } = apiDelegationApi; +export const { useDelegationCheckMutation, useSearchQuery, usePostApiDelegationMutation } = + apiDelegationApi; diff --git a/src/rtk/features/apiDelegation/delegableApi/delegableApiSlice.ts b/src/rtk/features/apiDelegation/delegableApi/delegableApiSlice.ts index 0b4167516..0a3aa0a94 100644 --- a/src/rtk/features/apiDelegation/delegableApi/delegableApiSlice.ts +++ b/src/rtk/features/apiDelegation/delegableApi/delegableApiSlice.ts @@ -2,6 +2,11 @@ import { createSlice } from '@reduxjs/toolkit'; import type { IdValuePair } from '@/dataObjects/dtos/IdValuePair'; +export interface ApiDelegation { + orgName: string; + apiName: string; +} + export interface DelegableApi { identifier: string; apiName: string; diff --git a/src/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice.ts b/src/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice.ts deleted file mode 100644 index b0b52cd49..000000000 --- a/src/rtk/features/apiDelegation/delegationRequest/delegationRequestSlice.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import axios from 'axios'; - -import { getCookie } from '@/resources/Cookie/CookieMethods'; - -export interface ApiDelegation { - orgName: string; - apiName: string; -} - -export interface SliceState { - loading: boolean; - error: string; - succesfulApiDelegations: ApiDelegation[]; - failedApiDelegations: ApiDelegation[]; - batchPostSize: number; - batchPostCounter: number; -} - -export interface DelegationRequest { - apiIdentifier: string; - apiName: string; - orgNr: string; - orgName: string; -} - -const initialState: SliceState = { - loading: true, - error: '', - succesfulApiDelegations: [], - failedApiDelegations: [], - batchPostSize: 0, - batchPostCounter: 0, -}; - -export const postApiDelegation = createAsyncThunk( - 'delegationRequestSlice/postApiDelegation', - async (delegationInfo: DelegationRequest) => { - const { apiIdentifier, apiName, orgNr, orgName }: DelegationRequest = delegationInfo; - const delegation: ApiDelegation = { - apiName, - orgName, - }; - const altinnPartyId = getCookie('AltinnPartyId'); - - if (!altinnPartyId) { - throw new Error(String('Could not get AltinnPartyId cookie value')); - } - - return await axios - .post(`/accessmanagement/api/v1/apidelegation/${altinnPartyId}/offered`, { - to: [ - { - id: 'urn:altinn:organizationnumber', - value: String(orgNr), - }, - ], - rights: [ - { - resource: [ - { - id: 'urn:altinn:resource', - value: String(apiIdentifier), - }, - ], - }, - ], - }) - .then(() => { - return delegation; - }) - .catch((error) => { - throw error; - }); - }, -); - -const delegationRequestSlice = createSlice({ - name: 'delegationRequest', - initialState, - reducers: { - resetDelegationRequests: () => initialState, - setBatchPostSize: (state, action) => { - state.batchPostSize = action.payload; - }, - }, - extraReducers: (builder) => { - builder - .addCase(postApiDelegation.fulfilled, (state, action) => { - const sucessfulDelegations = state.succesfulApiDelegations; - const delegation: ApiDelegation = { - apiName: action.payload.apiName, - orgName: action.payload.orgName, - }; - - sucessfulDelegations.push(delegation); - state.succesfulApiDelegations = sucessfulDelegations.sort((a, b) => { - const orgNameCompared = a.orgName.localeCompare(b.orgName); - if (orgNameCompared === 0) { - // Equal orgnames - return a.apiName.localeCompare(b.apiName); - } - return orgNameCompared; - }); - state.batchPostCounter += 1; - if (state.batchPostCounter === state.batchPostSize) { - state.loading = false; - } - }) - .addCase(postApiDelegation.rejected, (state, action) => { - const failedDelegations = state.failedApiDelegations; - const delegation: ApiDelegation = { - apiName: action.meta.arg.apiName, - orgName: action.meta.arg.orgName, - }; - failedDelegations.push(delegation); - state.failedApiDelegations = failedDelegations.sort((a, b) => { - const orgNameCompared = a.orgName.localeCompare(b.orgName); - if (orgNameCompared === 0) { - // Equal orgnames - return a.apiName.localeCompare(b.apiName); - } - return orgNameCompared; - }); - state.batchPostCounter += 1; - if (state.batchPostCounter === state.batchPostSize) { - state.loading = false; - } - }); - }, -}); - -export default delegationRequestSlice.reducer; -export const { resetDelegationRequests, setBatchPostSize } = delegationRequestSlice.actions;