From ea848b6206724503055f33fc6c1d3738c71d154f Mon Sep 17 00:00:00 2001 From: Alexandra Vedeler Date: Wed, 15 Jan 2025 13:40:49 +0100 Subject: [PATCH] Add new person as user in organization (#1220) * can now add new user (person) * Add error handling and tests; update localization strings * made story and mocked new endpoint in GUI * added check of ModelState to BFF endpoint * fixed missing sign * minor fixes * fix warning * added yet another test * renamed file * remove unnecesary line --------- Co-authored-by: Vedeler --- .mock/handlers/user.ts | 15 ++ .../ClientInterfaces/IRegisterClient.cs | 80 ++++++---- .../Services/Interfaces/IUserService.cs | 8 + .../Services/UserService.cs | 34 ++++- .../ClientUtils.cs | 27 +++- .../Clients/RegisterClient.cs | 39 ++++- .../ebd192ea-753b-448e-9226-95e8c439bd9f.json | 2 + .../Data/Register/Persons/19895199357.json | 9 ++ .../Data/Register/Persons/20838198385.json | 9 ++ .../Data/Register/Persons/21915399719.json | 9 ++ .../ebd192ea-753b-448e-9226-95e8c439bd9f.json | 4 + .../Mocks/RegisterClientMock.cs | 67 +++++++-- .../Controllers/UserControllerTest.cs | 141 ++++++++++++++++++ .../Controllers/UserController.cs | 56 ++++++- .../Models/ValidatePersonInput.cs | 18 +++ .../users/NewUserModal/NewPersonContent.tsx | 62 ++++++++ .../amUI/users/NewUserModal/NewUserAlert.tsx | 53 +++++++ .../NewUserModal/NewUserModal.module.css | 18 +++ .../NewUserModal/NewUserModal.stories.tsx | 20 +++ .../amUI/users/NewUserModal/NewUserModal.tsx | 65 ++++++++ src/features/amUI/users/UsersList.module.css | 8 +- src/features/amUI/users/UsersList.tsx | 26 ++-- src/localizations/en.json | 16 +- src/localizations/no_nb.json | 16 +- src/localizations/no_nn.json | 16 +- src/rtk/features/userInfoApi.ts | 13 ++ 26 files changed, 763 insertions(+), 68 deletions(-) create mode 100644 backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/AccessPackage/GetDelegations/ebd192ea-753b-448e-9226-95e8c439bd9f.json create mode 100644 backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/19895199357.json create mode 100644 backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/20838198385.json create mode 100644 backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/21915399719.json create mode 100644 backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/RightHolders/RightHolderAccess/ebd192ea-753b-448e-9226-95e8c439bd9f.json create mode 100644 backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Models/ValidatePersonInput.cs create mode 100644 src/features/amUI/users/NewUserModal/NewPersonContent.tsx create mode 100644 src/features/amUI/users/NewUserModal/NewUserAlert.tsx create mode 100644 src/features/amUI/users/NewUserModal/NewUserModal.module.css create mode 100644 src/features/amUI/users/NewUserModal/NewUserModal.stories.tsx create mode 100644 src/features/amUI/users/NewUserModal/NewUserModal.tsx diff --git a/.mock/handlers/user.ts b/.mock/handlers/user.ts index 32eb3f576..e404a8744 100644 --- a/.mock/handlers/user.ts +++ b/.mock/handlers/user.ts @@ -35,4 +35,19 @@ export const userHandlers = (ACCESSMANAGEMENT_BASE_URL: string) => [ }, }); }), + http.post( + new RegExp(`${ACCESSMANAGEMENT_BASE_URL}/user/reportee/(?:/[^/]+)?/rightholder/person`), + async (req: any) => { + const requestBody = await req.request.json(); + const ssn = requestBody?.ssn; + const lastName = requestBody?.lastName; + if (lastName.length < 4 || ssn.length !== 11) { + return HttpResponse.json({ error: 'Invalid input' }, { status: 404 }); + } else { + return HttpResponse.json({ + partyUuid: '54f128f7-ca7c-4a57-ad49-3787eb79b506', + }); + } + }, + ), ]; diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/ClientInterfaces/IRegisterClient.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/ClientInterfaces/IRegisterClient.cs index d660f20af..50fd9a5d3 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/ClientInterfaces/IRegisterClient.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/ClientInterfaces/IRegisterClient.cs @@ -2,37 +2,59 @@ namespace Altinn.AccessManagement.UI.Core.ClientInterfaces { - /// - /// Interface for client wrapper for integration with the platform register API - /// - public interface IRegisterClient - { /// - /// Looks up party information for an organization based on the organization number + /// Interface for client wrapper for integration with the platform register API /// - /// The organization number - /// - /// Party information - /// - Task GetPartyForOrganization(string organizationNumber); + public interface IRegisterClient + { + /// + /// Looks up party information for an organization based on the organization number + /// + /// The organization number + /// + /// Party information + /// + Task GetPartyForOrganization(string organizationNumber); - /// - /// Looks up party information for a list of uuids - /// - /// The list of uuids to be looked up - /// - /// A list of party information corresponding to the provided uuids - /// - Task> GetPartyList(List uuidList); + /// + /// Looks up party information for a person based on the ssn + /// + /// The persons ssn + /// + /// Party information + /// + Task GetPartyForPerson(string ssn); - /// - /// Looks up party name for a list of orgNumbers - /// - /// The list of organisation numbers to be looked up - /// Cancellation token - /// - /// A list of party organisation numbers with corresponding names - /// - Task> GetPartyNames(IEnumerable orgNumbers, CancellationToken cancellationToken); - } + /// + /// Looks up party information for a list of uuids + /// + /// The list of uuids to be looked up + /// + /// A list of party information corresponding to the provided uuids + /// + Task> GetPartyList(List uuidList); + + /// + /// Looks up a person based on ssn and lastname. + /// The endpoint will track the number of failed lookup attempts and block further requests if the number of failed + /// lookups have exceeded a threshold number. The user will be prevented from performing new searches + /// for a set time. (by throwing a 429 status exception) + /// + /// The persons ssn - must match latname + /// The persons lastname - must match ssn + /// + /// Person information + /// + Task GetPerson(string ssn, string lastname); + + /// + /// Looks up party name for a list of orgNumbers + /// + /// The list of organisation numbers to be looked up + /// Cancellation token + /// + /// A list of party organisation numbers with corresponding names + /// + Task> GetPartyNames(IEnumerable orgNumbers, CancellationToken cancellationToken); + } } diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IUserService.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IUserService.cs index edddcd0cb..37be28c02 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IUserService.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/Interfaces/IUserService.cs @@ -37,5 +37,13 @@ public interface IUserService /// The uuid for the right holder whose accesses are to be returned /// All right holder's accesses Task GetRightHolderAccesses(string reporteeUuid, string rightHolderUuid); + + /// + /// Checks that a person with the provided ssn and lastname exists. If they do, the person's partyUuid is returned. + /// + /// The ssn of the user + /// The last name of the user + /// The person's partyUuid if ssn and lastname correspond to the same person. Returns null if matching person is not found + Task ValidatePerson(string ssn, string lastname); } } diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/UserService.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/UserService.cs index c507fa592..c828c8adc 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/UserService.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Core/Services/UserService.cs @@ -1,10 +1,6 @@ using Altinn.AccessManagement.UI.Core.ClientInterfaces; -using Altinn.AccessManagement.UI.Core.Enums; using Altinn.AccessManagement.UI.Core.Models; using Altinn.AccessManagement.UI.Core.Models.AccessManagement; -using Altinn.AccessManagement.UI.Core.Models.Delegation; -using Altinn.AccessManagement.UI.Core.Models.Delegation.Frontend; -using Altinn.AccessManagement.UI.Core.Models.ResourceRegistry; using Altinn.AccessManagement.UI.Core.Models.User; using Altinn.AccessManagement.UI.Core.Services.Interfaces; using Altinn.Platform.Profile.Models; @@ -22,6 +18,7 @@ public class UserService : IUserService private readonly IProfileClient _profileClient; private readonly IAccessManagementClient _accessManagementClient; private readonly IAccessManagementClientV0 _accessManagementClientV0; + private readonly IRegisterClient _registerClient; /// /// Initializes a new instance of the class. @@ -30,16 +27,19 @@ public class UserService : IUserService /// handler for profile client /// handler for AM client /// handler for old AM client + /// handler for register client public UserService( ILogger logger, IProfileClient profileClient, IAccessManagementClient accessManagementClient, - IAccessManagementClientV0 accessManagementClientV0) + IAccessManagementClientV0 accessManagementClientV0, + IRegisterClient registerClient) { _logger = logger; _profileClient = profileClient; _accessManagementClient = accessManagementClient; _accessManagementClientV0 = accessManagementClientV0; + _registerClient = registerClient; } /// @@ -69,5 +69,29 @@ public Task GetRightHolderAccesses(string reporteeUuid, str { return _accessManagementClient.GetRightHolderAccesses(reporteeUuid, rightHolderUuid); } + + /// + public async Task ValidatePerson(string ssn, string lastname) + { + // Check for bad input + string ssn_cleaned = ssn.Trim().Replace("\"", string.Empty); + string lastname_cleaned = lastname.Trim().Replace("\"", string.Empty); + if (ssn_cleaned.Length != 11 || !ssn_cleaned.All(char.IsDigit)) + { + return null; + } + + // Check that a person with the provided ssn and last name exists + Person person = await _registerClient.GetPerson(ssn_cleaned, lastname_cleaned); + + if (person == null) + { + return null; + } + + Party personParty = await _registerClient.GetPartyForPerson(ssn_cleaned); + + return personParty?.PartyUuid; + } } } diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/ClientUtils.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/ClientUtils.cs index c6219213b..ff893d19d 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/ClientUtils.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/ClientUtils.cs @@ -1,6 +1,6 @@ -using System.Net; -using System.Text.Json; +using System.Text.Json; using Altinn.AccessManagement.UI.Core.Helpers; +using Microsoft.Extensions.Logging; namespace Altinn.AccessManagement.UI.Integration.Util { @@ -14,8 +14,10 @@ public class ClientUtils /// /// The type that the response is to be deserialized into /// The response message that is to be deserialized + /// The client logger to be used if errors are to be logged + /// The client name and method name to be used in logging if a logger is provided /// The response, deserialized into an object of type T - public async static Task DeserializeIfSuccessfullStatusCode(HttpResponseMessage response) + public async static Task DeserializeIfSuccessfullStatusCode(HttpResponseMessage response, ILogger logger = null, string clientMethodName = "") { JsonSerializerOptions serializerOptions = new JsonSerializerOptions { @@ -29,8 +31,23 @@ public async static Task DeserializeIfSuccessfullStatusCode(HttpResponseMe } else { - string responseContent = await response.Content.ReadAsStringAsync(); - HttpStatusException error = JsonSerializer.Deserialize(responseContent, serializerOptions); + string responseContent = string.Empty; + HttpStatusException error; + if (response.Content.Headers.ContentLength > 0) + { + responseContent = await response.Content.ReadAsStringAsync(); + error = JsonSerializer.Deserialize(responseContent, serializerOptions); + if (error.StatusCode != response.StatusCode) + { + error.StatusCode = response.StatusCode; + } + } + else + { + error = new HttpStatusException("General", response.ReasonPhrase, response.StatusCode, string.Empty); + } + + logger?.LogError($"AccessManagement.UI // {clientMethodName} // Unexpected HttpStatusCode: {response.StatusCode}\n {responseContent}"); throw error; } diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/Clients/RegisterClient.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/Clients/RegisterClient.cs index 401a6b8a8..8733138a7 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/Clients/RegisterClient.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Integration/Clients/RegisterClient.cs @@ -1,12 +1,15 @@ using System.Diagnostics.CodeAnalysis; using System.Net; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Altinn.AccessManagement.UI.Core.ClientInterfaces; using Altinn.AccessManagement.UI.Core.Extensions; +using Altinn.AccessManagement.UI.Core.Models.AccessPackage; using Altinn.AccessManagement.UI.Core.Services.Interfaces; using Altinn.AccessManagement.UI.Integration.Configuration; +using Altinn.AccessManagement.UI.Integration.Util; using Altinn.Platform.Register.Models; using AltinnCore.Authentication.Utils; using Microsoft.AspNetCore.Http; @@ -41,11 +44,11 @@ public RegisterClient( ILogger logger, IHttpContextAccessor httpContextAccessor, IOptions platformSettings, - IAccessTokenProvider accessTokenProvider) + IAccessTokenProvider accessTokenProvider) { _logger = logger; _platformSettings = platformSettings.Value; - httpClient.BaseAddress = new Uri(_platformSettings.ApiRegisterEndpoint); + httpClient.BaseAddress = new Uri(_platformSettings.ApiRegisterEndpoint); httpClient.DefaultRequestHeaders.Add(_platformSettings.SubscriptionKeyHeaderName, _platformSettings.SubscriptionKey); _client = httpClient; _httpContextAccessor = httpContextAccessor; @@ -82,6 +85,20 @@ public async Task GetPartyForOrganization(string organizationNumber) } } + /// + public async Task GetPartyForPerson(string ssn) + { + string endpointUrl = $"parties/lookup"; + string token = JwtTokenUtil.GetTokenFromContext(_httpContextAccessor.HttpContext, _platformSettings.JwtCookieName); + var accessToken = await _accessTokenProvider.GetAccessToken(); + + StringContent requestBody = new StringContent(JsonSerializer.Serialize(new PartyLookup { Ssn = ssn }, _serializerOptions), Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client.PostAsync(token, endpointUrl, requestBody, accessToken); + + return await ClientUtils.DeserializeIfSuccessfullStatusCode(response, _logger, "RegisterClient // GetPartyForPerson"); + } + /// public async Task> GetPartyList(List uuidList) { @@ -111,6 +128,24 @@ public async Task> GetPartyList(List uuidList) } } + /// + public async Task GetPerson(string ssn, string lastname) + { + string endpointUrl = $"persons"; + string token = JwtTokenUtil.GetTokenFromContext(_httpContextAccessor.HttpContext, _platformSettings.JwtCookieName); + var platformAccessToken = await _accessTokenProvider.GetAccessToken(); + + var request = new HttpRequestMessage(HttpMethod.Get, endpointUrl); + request.Headers.Add("X-Ai-NationalIdentityNumber", ssn); + request.Headers.Add("X-Ai-LastName", lastname); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("PlatformAccessToken", platformAccessToken); + + HttpResponseMessage response = await _client.SendAsync(request); + + return await ClientUtils.DeserializeIfSuccessfullStatusCode(response); + } + /// public async Task> GetPartyNames(IEnumerable orgNumbers, CancellationToken cancellationToken) { diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/AccessPackage/GetDelegations/ebd192ea-753b-448e-9226-95e8c439bd9f.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/AccessPackage/GetDelegations/ebd192ea-753b-448e-9226-95e8c439bd9f.json new file mode 100644 index 000000000..0d4f101c7 --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/AccessPackage/GetDelegations/ebd192ea-753b-448e-9226-95e8c439bd9f.json @@ -0,0 +1,2 @@ +[ +] diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/19895199357.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/19895199357.json new file mode 100644 index 000000000..241c821ec --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/19895199357.json @@ -0,0 +1,9 @@ +{ + "FirstName": "Intelligent", + "MiddleName": "", + "LastName": "Albatross", + "Address": "Gata 2", + "MailingAddress": "nada@nada.no", + "DateOfBirth": null, + "DateOfDeath": null +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/20838198385.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/20838198385.json new file mode 100644 index 000000000..f92c70f5f --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/20838198385.json @@ -0,0 +1,9 @@ +{ + "FirstName": "Sitrongul", + "MiddleName": "", + "LastName": "Medaljong", + "Address": "Gata", + "MailingAddress": "nada@nada.no", + "DateOfBirth": null, + "DateOfDeath": null +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/21915399719.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/21915399719.json new file mode 100644 index 000000000..4d234a7ff --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/Register/Persons/21915399719.json @@ -0,0 +1,9 @@ +{ + "FirstName": "Livsglad", + "MiddleName": "", + "LastName": "Film", + "Address": "Gata 3", + "MailingAddress": "nada@nada.no", + "DateOfBirth": null, + "DateOfDeath": null +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/RightHolders/RightHolderAccess/ebd192ea-753b-448e-9226-95e8c439bd9f.json b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/RightHolders/RightHolderAccess/ebd192ea-753b-448e-9226-95e8c439bd9f.json new file mode 100644 index 000000000..a0cb85db5 --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Data/RightHolders/RightHolderAccess/ebd192ea-753b-448e-9226-95e8c439bd9f.json @@ -0,0 +1,4 @@ +{ + "accessPackages": [], + "services": [] +} diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Mocks/RegisterClientMock.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Mocks/RegisterClientMock.cs index c863e1001..ab55c788a 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Mocks/RegisterClientMock.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Mocks/Mocks/RegisterClientMock.cs @@ -1,5 +1,7 @@ -using System.Text.Json; +using System.Net; +using System.Text.Json; using Altinn.AccessManagement.UI.Core.ClientInterfaces; +using Altinn.AccessManagement.UI.Core.Helpers; using Altinn.Platform.Register.Models; namespace Altinn.AccessManagement.UI.Mocks.Mocks @@ -9,7 +11,9 @@ namespace Altinn.AccessManagement.UI.Mocks.Mocks /// public class RegisterClientMock : IRegisterClient { - private static readonly JsonSerializerOptions options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + private static int _numberOfFaliedPersonLookups = 0; + private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + /// /// Initializes a new instance of the class /// @@ -41,26 +45,71 @@ public Task> GetPartyList(List uuidList) if (File.Exists(testDataPath)) { string content = File.ReadAllText(testDataPath); - List partyList = JsonSerializer.Deserialize>(content, options); + List partyList = JsonSerializer.Deserialize>(content, _options); return Task.FromResult(new List() { partyList?.FirstOrDefault(p => p.PartyUuid == uuidList[0]) }); } return Task.FromResult(new List { }); } + /// + public async Task GetPerson(string ssn, string lastname) + { + Person person = null; + string testDataPath = Path.Combine(Path.GetDirectoryName(new Uri(typeof(RegisterClientMock).Assembly.Location).LocalPath), "Data", "Register", "Persons", $"{ssn}.json"); + if (File.Exists(testDataPath)) + { + string content = File.ReadAllText(testDataPath); + Person personContent = JsonSerializer.Deserialize(content, _options); + if (personContent.LastName.ToLower() == lastname.ToLower()) + { + person = personContent; + } + } + + if (person == null) + { + _numberOfFaliedPersonLookups++; + if (_numberOfFaliedPersonLookups > 3) + { + throw new HttpStatusException("Status Error", "Too many failed person lookups", HttpStatusCode.TooManyRequests, null); + } + else + { + throw new HttpStatusException("Status Error", "Person not found", HttpStatusCode.NotFound, null); + } + } + return await Task.FromResult(person); + } + + /// + public async Task GetPartyForPerson(string ssn) + { + Party party = null; + string testDataPath = Path.Combine(Path.GetDirectoryName(new Uri(typeof(RegisterClientMock).Assembly.Location).LocalPath), "Data", "Register", "Parties", "parties.json"); + if (File.Exists(testDataPath)) + { + string content = File.ReadAllText(testDataPath); + List partyList = JsonSerializer.Deserialize>(content, _options); + party = partyList?.FirstOrDefault(p => p.SSN == ssn); + } + + return await Task.FromResult(party); + } public Task> GetPartyNames(IEnumerable orgNumbers, CancellationToken cancellationToken) { string testDataPath = Path.Combine(Path.GetDirectoryName(new Uri(typeof(RegisterClientMock).Assembly.Location).LocalPath), "Data", "Register", "Parties", "parties.json"); if (File.Exists(testDataPath)) { string content = File.ReadAllText(testDataPath); - List partyList = JsonSerializer.Deserialize>(content, options); - List partyNames = partyList?.Where(party => orgNumbers.Contains(party.OrgNumber)).Select(p => + List partyList = JsonSerializer.Deserialize>(content, _options); + List partyNames = partyList?.Where(party => orgNumbers.Contains(party.OrgNumber)).Select(p => { - return new PartyName { - OrgNo = p.Organization?.OrgNumber, - Name = p.Organization?.Name, - Ssn = p.SSN + return new PartyName + { + OrgNo = p.Organization?.OrgNumber, + Name = p.Organization?.Name, + Ssn = p.SSN }; }).ToList(); return Task.FromResult(partyNames); diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/UserControllerTest.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/UserControllerTest.cs index ee1bea1f7..870e03903 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/UserControllerTest.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI.Tests/Controllers/UserControllerTest.cs @@ -1,17 +1,21 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; +using System.Text.Json; using Altinn.AccessManagement.UI.Controllers; using Altinn.AccessManagement.UI.Core.ClientInterfaces; using Altinn.AccessManagement.UI.Core.Models; using Altinn.AccessManagement.UI.Core.Models.AccessManagement; using Altinn.AccessManagement.UI.Core.Models.User; using Altinn.AccessManagement.UI.Mocks.Utils; +using Altinn.AccessManagement.UI.Models; using Altinn.AccessManagement.UI.Tests.Utils; using Altinn.Platform.Profile.Enums; using Altinn.Platform.Profile.Models; using Microsoft.AspNetCore.Http; using Moq; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace Altinn.AccessManagement.UI.Tests.Controllers { @@ -206,5 +210,142 @@ public async Task GetRightholderAccesses_Invalid_Input() Assert.False(response.IsSuccessStatusCode); } + + /// + /// Test case: Validate a valid person + /// Expected: Returns a 200 and the party uuid of the person + /// + [Fact] + public async Task ValidatePerson_ValidInput() + { + // Arrange + var partyId = 51329012; + var ssn = "20838198385"; + var lastname = "Medaljong"; + + var token = PrincipalUtil.GetToken(1234, 1234, 2); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + ValidatePersonInput input = new ValidatePersonInput { Ssn = ssn, LastName = lastname }; + string jsonRights = JsonSerializer.Serialize(input); + HttpContent content = new StringContent(jsonRights, Encoding.UTF8, "application/json"); + + var expectedResponse = Guid.Parse("167536b5-f8ed-4c5a-8f48-0279507e53ae"); + + // Act + HttpResponseMessage httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + + // Assert + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + + var response = await httpResponse.Content.ReadFromJsonAsync(); + Assert.Equal(expectedResponse, response); + + } + + /// + /// Test case: Enter invalid input + /// Expected: Returns a 404 not found + /// + [Fact] + public async Task ValidatePerson_InvalidInput() + { + // Arrange + var partyId = 51329012; + var ssn = "2083819838a"; // Invalid ssn + var lastname = "Medaljong"; + + var token = PrincipalUtil.GetToken(1234, 1234, 2); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + ValidatePersonInput input = new ValidatePersonInput { Ssn = ssn, LastName = lastname }; + string jsonRights = JsonSerializer.Serialize(input); + HttpContent content = new StringContent(jsonRights, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, httpResponse.StatusCode); + } + + /// + /// Test case: Enter an ivalid ssn and last name combination + /// Expected: Returns a 404 not found + /// + [Fact] + public async Task ValidatePerson_InvalidInputCombination() + { + // Arrange + var partyId = 51329012; + var ssn = "20838198385"; // Valid ssn + var lastname = "Albatross"; // Valid last name, but does not belong to person with given ssn + + var token = PrincipalUtil.GetToken(1234, 1234, 2); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + ValidatePersonInput input = new ValidatePersonInput { Ssn = ssn, LastName = lastname }; + string jsonRights = JsonSerializer.Serialize(input); + HttpContent content = new StringContent(jsonRights, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, httpResponse.StatusCode); + } + + /// + /// Test case: Enter invalid data too many times + /// Expected: Returns a 429 - too many requests + /// + [Fact] + public async Task ValidatePerson_TooManyRequests() + { + // Arrange + var partyId = 51329012; + var ssn = "20838198311"; // Invalid ssn + var lastname = "Hansen"; // Invalid name combination + + var token = PrincipalUtil.GetToken(1234, 1234, 2); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + ValidatePersonInput input = new ValidatePersonInput { Ssn = ssn, LastName = lastname }; + string jsonRights = JsonSerializer.Serialize(input); + HttpContent content = new StringContent(jsonRights, Encoding.UTF8, "application/json"); + + // Act - reapeat the request 4 times to lock the user + HttpResponseMessage httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + + // Assert + Assert.Equal(HttpStatusCode.TooManyRequests, httpResponse.StatusCode); + } + + /// + /// Test case: Sending the wrong input in body triggers a babd request + /// Expected: Returns a 400 - bad request + /// + [Fact] + public async Task ValidatePerson_BadRequest() + { + // Arrange + var partyId = 51329012; + string input = "The wrong input"; + + var token = PrincipalUtil.GetToken(1234, 1234, 2); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + string jsonRights = JsonSerializer.Serialize(input); + HttpContent content = new StringContent(jsonRights, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage httpResponse = await _client.PostAsync($"accessmanagement/api/v1/user/reportee/{partyId}/rightholder/person", content); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, httpResponse.StatusCode); + } } } \ No newline at end of file diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/UserController.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/UserController.cs index e8c8ea4ce..4629dd1ca 100644 --- a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/UserController.cs +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Controllers/UserController.cs @@ -1,11 +1,10 @@ -using Altinn.AccessManagement.UI.Core.ClientInterfaces; +using System.Net; using Altinn.AccessManagement.UI.Core.Helpers; using Altinn.AccessManagement.UI.Core.Models; using Altinn.AccessManagement.UI.Core.Models.AccessManagement; using Altinn.AccessManagement.UI.Core.Models.User; using Altinn.AccessManagement.UI.Core.Services.Interfaces; -using Altinn.Platform.Profile.Models; -using Altinn.Platform.Register.Models; +using Altinn.AccessManagement.UI.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -117,6 +116,57 @@ public async Task>> GetReporteeRightHolders(int p } } + /// + /// Endpoint for validating a new rightholder through an ssn and last name combination. + /// If the ssn and last name does not match, the endpoint will return 404. + /// If the endpont is called with unmatching input too many times, the user calling the endpoint will be blocked for an hour and the request will return 429. + /// If the combination is valid, the endpoint will return the partyUuid of the person. + /// + /// The ssn and last name of the person to be looked up + /// The partyUuid of the person + /// Bad Request + /// Internal Server Error + [HttpPost] + [Authorize] + [Route("reportee/{partyId}/rightholder/person")] + public async Task> ValidatePerson([FromBody] ValidatePersonInput validationInput) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + try + { + Guid? partyUuid = await _userService.ValidatePerson(validationInput.Ssn, validationInput.LastName); + + if (partyUuid != null) + { + return partyUuid; + } + else + { + return StatusCode(404); + } + } + catch (HttpStatusException ex) + { + if (ex.StatusCode == HttpStatusCode.TooManyRequests) + { + return StatusCode(429); + } + else + { + return new ObjectResult(ProblemDetailsFactory.CreateProblemDetails(HttpContext, (int?)ex.StatusCode, "Unexpected HttpStatus response", detail: ex.Message)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GetUserByUUID failed to fetch party information"); + return StatusCode(500); + } + } + /// /// Endpoint for retrieving all accesses a specified right holder has on behalf of a party (the reportee) /// diff --git a/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Models/ValidatePersonInput.cs b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Models/ValidatePersonInput.cs new file mode 100644 index 000000000..7bc3e8d29 --- /dev/null +++ b/backend/src/Altinn.AccessManagement.UI/Altinn.AccessManagement.UI/Models/ValidatePersonInput.cs @@ -0,0 +1,18 @@ +namespace Altinn.AccessManagement.UI.Models +{ + /// + /// Model for input when validating a person + /// + public class ValidatePersonInput + { + /// + /// The social security number of the person to validate + /// + public string Ssn { get; set; } + + /// + /// The last name of the person to validate + /// + public string LastName { get; set; } + } +} diff --git a/src/features/amUI/users/NewUserModal/NewPersonContent.tsx b/src/features/amUI/users/NewUserModal/NewPersonContent.tsx new file mode 100644 index 000000000..5aed64180 --- /dev/null +++ b/src/features/amUI/users/NewUserModal/NewPersonContent.tsx @@ -0,0 +1,62 @@ +import { Button, TextField } from '@altinn/altinn-components'; +import { useState } from 'react'; +import { t } from 'i18next'; + +import { useValidateNewUserPersonMutation } from '@/rtk/features/userInfoApi'; + +import classes from './NewUserModal.module.css'; +import { NewUserAlert } from './NewUserAlert'; + +export const NewPersonContent = () => { + const [ssn, setSsn] = useState(''); + const [lastName, setLastName] = useState(''); + const [errorTime, setErrorTime] = useState(''); + + const [validateNewPerson, { error, isError, isLoading }] = useValidateNewUserPersonMutation(); + + const errorDetails = + isError && error && 'status' in error + ? { + status: error.status.toString(), + time: errorTime, + } + : null; + + const navigateIfValidPerson = () => { + validateNewPerson({ ssn, lastName }) + .unwrap() + .then((userUuid) => { + window.location.href = `${window.location.href}/${userUuid}`; + }) + .catch(() => { + setErrorTime(new Date().toISOString()); + }); + }; + + return ( +
+ {isError && } + setSsn((e.target as HTMLInputElement).value)} + /> + setLastName((e.target as HTMLInputElement).value)} + /> +
+ +
+
+ ); +}; diff --git a/src/features/amUI/users/NewUserModal/NewUserAlert.tsx b/src/features/amUI/users/NewUserModal/NewUserAlert.tsx new file mode 100644 index 000000000..c9a482cab --- /dev/null +++ b/src/features/amUI/users/NewUserModal/NewUserAlert.tsx @@ -0,0 +1,53 @@ +import { Alert, Paragraph } from '@digdir/designsystemet-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface NewUserAlertProps { + /*** The technical error if one has occured */ + error?: { status: string; time: string } | null; +} + +export const NewUserAlert = ({ error }: NewUserAlertProps) => { + const { t } = useTranslation(); + let errorText; + + if (error && error.status === '404') { + errorText = {t('new_user_modal.not_found_error')}; + } else if (error && error.status === '429') { + errorText = {t('new_user_modal.too_many_requests_error')}; + } else if (error) { + errorText = ( + <> + + {t('common.technical_error')} + + + {t('common.time_of_error', { + time: new Date(error.time).toLocaleDateString('nb-NO', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + })} + + + {t('common.error_status', { + status: error.status, + })} + + + ); + } + + return ( + + {errorText} + + ); +}; diff --git a/src/features/amUI/users/NewUserModal/NewUserModal.module.css b/src/features/amUI/users/NewUserModal/NewUserModal.module.css new file mode 100644 index 000000000..f42d8dd58 --- /dev/null +++ b/src/features/amUI/users/NewUserModal/NewUserModal.module.css @@ -0,0 +1,18 @@ +.textField { + max-width: 22rem; +} + +.modalHeading { + margin-bottom: 10px; +} + +.validationButton { + margin-top: 10px; +} + +.newPersonContent { + padding-top: 5px; + display: flex; + flex-direction: column; + gap: 15px; +} diff --git a/src/features/amUI/users/NewUserModal/NewUserModal.stories.tsx b/src/features/amUI/users/NewUserModal/NewUserModal.stories.tsx new file mode 100644 index 000000000..3fc921d72 --- /dev/null +++ b/src/features/amUI/users/NewUserModal/NewUserModal.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Provider } from 'react-redux'; + +import store from '@/rtk/app/store'; + +import { NewUserButton } from './NewUserModal'; + +export default { + title: 'Features/AMUI/NewUserModal', + component: NewUserButton, + render: () => ( + + + + ), +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/src/features/amUI/users/NewUserModal/NewUserModal.tsx b/src/features/amUI/users/NewUserModal/NewUserModal.tsx new file mode 100644 index 000000000..79571027a --- /dev/null +++ b/src/features/amUI/users/NewUserModal/NewUserModal.tsx @@ -0,0 +1,65 @@ +import React, { useRef } from 'react'; +import { Button } from '@altinn/altinn-components'; +import { Heading, Modal, Tabs } from '@digdir/designsystemet-react'; +import { t } from 'i18next'; + +import { NewPersonContent } from './NewPersonContent'; +import classes from './NewUserModal.module.css'; + +/** + * NewUserButton component renders a button that, when clicked, opens a modal to add a new user. + * @component + */ +export const NewUserButton: React.FC = () => { + const modalRef = useRef(null); + + return ( + <> + + + + ); +}; + +interface NewUserModalProps { + modalRef: React.RefObject; +} + +const NewUserModal: React.FC = ({ modalRef }) => { + return ( + + + {t('new_user_modal.modal_title')} + + + + {t('new_user_modal.person')} + {t('new_user_modal.organization')} + + + + + Kommer senere... + + + ); +}; + +export default NewUserModal; diff --git a/src/features/amUI/users/UsersList.module.css b/src/features/amUI/users/UsersList.module.css index 7be98861a..397b9010d 100644 --- a/src/features/amUI/users/UsersList.module.css +++ b/src/features/amUI/users/UsersList.module.css @@ -16,9 +16,15 @@ padding-top: 1rem; } +.searchAndAddUser { + display: flex; + align-items: center; + gap: 1rem; + padding-bottom: 10px; +} + .searchBar { max-width: 27rem; - padding-bottom: 10px; } .usersListHeading { diff --git a/src/features/amUI/users/UsersList.tsx b/src/features/amUI/users/UsersList.tsx index 824726b49..7b777849b 100644 --- a/src/features/amUI/users/UsersList.tsx +++ b/src/features/amUI/users/UsersList.tsx @@ -12,6 +12,7 @@ import { List } from '@/components'; import { useFilteredRightHolders } from './useFilteredRightHolders'; import classes from './UsersList.module.css'; +import { NewUserButton } from './NewUserModal/NewUserModal'; export const UsersList = () => { const { t } = useTranslation(); @@ -60,17 +61,20 @@ export const UsersList = () => { > {t('users_page.user_list_heading')} - onSearch(event.target.value)} - onClear={() => { - setSearchString(''); - setCurrentPage(1); - }} - hideLabel - label={t('users_page.user_search_placeholder')} - /> +
+ onSearch(event.target.value)} + onClear={() => { + setSearchString(''); + setCurrentPage(1); + }} + hideLabel + label={t('users_page.user_search_placeholder')} + /> + +
({ + query: ({ ssn, lastName }) => ({ + url: `reportee/${getCookie('AltinnPartyUuid')}/rightholder/person`, + method: 'POST', + body: JSON.stringify({ ssn, lastName }), + transformErrorResponse: (response: { + status: string | number; + }): { status: string | number } => { + return { status: response.status }; + }, + }), + }), }), }); @@ -81,6 +93,7 @@ export const { useGetReporteeQuery, useGetRightHoldersQuery, useGetRightHolderAccessesQuery, + useValidateNewUserPersonMutation, } = userInfoApi; export const { endpoints, reducerPath, reducer, middleware } = userInfoApi;