diff --git a/backend/PolicyAdmin/Models/AccessPackageArea.cs b/backend/PolicyAdmin/Models/AccessPackageArea.cs new file mode 100644 index 00000000000..ca77a51653e --- /dev/null +++ b/backend/PolicyAdmin/Models/AccessPackageArea.cs @@ -0,0 +1,21 @@ +#nullable enable + +namespace PolicyAdmin.Models +{ + public class AccessPackageArea + { + public required string Id { get; set; } + + public required string Urn { get; set; } + + public required string Name { get; set; } + + public string? Description { get; set; } + + public string? Icon { get; set; } + + public string? AreaGroup { get; set; } + + public IEnumerable Packages { get; set; } = []; + } +} diff --git a/backend/PolicyAdmin/Models/AccessPackageAreaGroup.cs b/backend/PolicyAdmin/Models/AccessPackageAreaGroup.cs new file mode 100644 index 00000000000..7b9896bf222 --- /dev/null +++ b/backend/PolicyAdmin/Models/AccessPackageAreaGroup.cs @@ -0,0 +1,17 @@ +namespace PolicyAdmin.Models +{ + public class AccessPackageAreaGroup + { + public required string Id { get; set; } + + public required string Urn { get; set; } + + public required string Name { get; set; } + + public string? Description { get; set; } + + public string? Type { get; set; } + + public IEnumerable Areas { get; set; } = []; + } +} diff --git a/backend/PolicyAdmin/Models/AccessPackageOption.cs b/backend/PolicyAdmin/Models/AccessPackageOption.cs new file mode 100644 index 00000000000..367f8e91eba --- /dev/null +++ b/backend/PolicyAdmin/Models/AccessPackageOption.cs @@ -0,0 +1,13 @@ +namespace PolicyAdmin.Models +{ + public class AccessPackageOption + { + public required string Id { get; set; } + + public required string Urn { get; set; } + + public required string Name { get; set; } + + public string? Description { get; set; } + } +} diff --git a/backend/PolicyAdmin/Models/PolicyRule.cs b/backend/PolicyAdmin/Models/PolicyRule.cs index 5e3c3e08c54..b38cd7c04c1 100644 --- a/backend/PolicyAdmin/Models/PolicyRule.cs +++ b/backend/PolicyAdmin/Models/PolicyRule.cs @@ -8,6 +8,8 @@ public class PolicyRule public List? Subject { get; set; } + public List? AccessPackages { get; set; } + public List? Actions { get; set; } public List>? Resources { get; set; } diff --git a/backend/PolicyAdmin/PolicyConverter.cs b/backend/PolicyAdmin/PolicyConverter.cs index c049c0ab471..5575d7cc7e1 100644 --- a/backend/PolicyAdmin/PolicyConverter.cs +++ b/backend/PolicyAdmin/PolicyConverter.cs @@ -21,6 +21,7 @@ public static ResourcePolicy ConvertPolicy(XacmlPolicy xacmlPolicy) rule.Description = xr.Description; rule.Subject = new List(); + rule.AccessPackages = new List(); rule.Actions = new List(); rule.Resources = new List>(); @@ -31,7 +32,9 @@ public static ResourcePolicy ConvertPolicy(XacmlPolicy xacmlPolicy) { foreach (XacmlAllOf allOf in anyOf.AllOf) { - List? subject = GetRuleSubjects(allOf); + List? subject = GetRuleSubjects(allOf)?.Where(x => !x.StartsWith("urn:altinn:accesspackage")).ToList(); + + List? accessPackages = GetRuleSubjects(allOf)?.Where(x => x.StartsWith("urn:altinn:accesspackage")).ToList(); List? resource = GetRuleResources(allOf); @@ -42,6 +45,11 @@ public static ResourcePolicy ConvertPolicy(XacmlPolicy xacmlPolicy) rule.Subject.AddRange(subject); } + if (accessPackages != null) + { + rule.AccessPackages.AddRange(accessPackages); + } + if (action != null) { rule.Actions.AddRange(action); @@ -195,6 +203,11 @@ private static XacmlRule ConvertRule(PolicyRule policyRule) ruleAnyOfs.Add(GetSubjectAnyOfs(policyRule.Subject)); } + if (policyRule.AccessPackages != null && policyRule.AccessPackages.Count > 0) + { + ruleAnyOfs.Add(GetSubjectAnyOfs(policyRule.AccessPackages)); + } + if (policyRule.Resources != null && policyRule.Resources.Count > 0) { ruleAnyOfs.Add(GetResourceAnyOfs(policyRule.Resources)); diff --git a/backend/src/Designer/Controllers/PolicyController.cs b/backend/src/Designer/Controllers/PolicyController.cs index b0646f5db31..ece9dad0956 100644 --- a/backend/src/Designer/Controllers/PolicyController.cs +++ b/backend/src/Designer/Controllers/PolicyController.cs @@ -165,6 +165,14 @@ public async Task GetActionOptions(string org, string app, Cancell return Ok(actionOptions); } + [HttpGet] + [Route("accesspackageoptions")] + public async Task GetAccessPackageOptions(string org, string app, CancellationToken cancellationToken) + { + List accessPackageOptions = await _policyOptions.GetAccessPackageOptions(cancellationToken); + return Ok(accessPackageOptions); + } + private ValidationProblemDetails ValidatePolicy(ResourcePolicy policy) { diff --git a/backend/src/Designer/Controllers/ResourceAdminController.cs b/backend/src/Designer/Controllers/ResourceAdminController.cs index ad3f565ba20..1601cd5c06a 100644 --- a/backend/src/Designer/Controllers/ResourceAdminController.cs +++ b/backend/src/Designer/Controllers/ResourceAdminController.cs @@ -427,6 +427,52 @@ public async Task>> GetEuroVoc(CancellationToken return sectors; } + [HttpGet] + [Route("designer/api/accesspackageservices/{accesspackage}/{env}")] + public async Task>> GetServicesForAccessPackage(string org, string accesspackage, string env) + { + // POST to get all resources per access package + List subjectResources = await _resourceRegistry.GetSubjectResources([accesspackage], env); + + // GET full list of resources (with apps) in environment + string cacheKey = $"resourcelist_with_apps${env}"; + if (!_memoryCache.TryGetValue(cacheKey, out List environmentResources)) + { + environmentResources = await _resourceRegistry.GetResourceList(env, false, true); + + MemoryCacheEntryOptions cacheEntryOptions = new MemoryCacheEntryOptions() + .SetPriority(CacheItemPriority.High) + .SetAbsoluteExpiration(new TimeSpan(0, _cacheSettings.DataNorgeApiCacheTimeout, 0)); + _memoryCache.Set(cacheKey, environmentResources, cacheEntryOptions); + } + + List resources = subjectResources.Find(x => x.Subject.Urn == accesspackage)?.Resources; + + OrgList orgList = await GetOrgList(); + List result = []; + + // return resources for all subjectResources + resources?.ForEach(resourceMatch => + { + ServiceResource fullResource = environmentResources.Find(x => x.Identifier == resourceMatch.Value); + + if (fullResource != null) + { + orgList.Orgs.TryGetValue(fullResource.HasCompetentAuthority.Orgcode.ToLower(), out Org organization); + + result.Add(new AccessPackageService() + { + Identifier = resourceMatch.Value, + Title = fullResource?.Title, + HasCompetentAuthority = fullResource.HasCompetentAuthority, + LogoUrl = organization.Logo + }); + } + }); + + return result; + } + [HttpGet] [Route("designer/api/{org}/resources/altinn2linkservices/{env}")] public async Task>> GetAltinn2LinkServices(string org, string env) diff --git a/backend/src/Designer/Models/AccessPackageService.cs b/backend/src/Designer/Models/AccessPackageService.cs new file mode 100644 index 00000000000..2def32383f4 --- /dev/null +++ b/backend/src/Designer/Models/AccessPackageService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Altinn.Studio.Designer.Models +{ + public class AccessPackageService + { + public string Identifier { get; set; } + + public Dictionary Title { get; set; } + + public CompetentAuthority HasCompetentAuthority { get; set; } + + public string LogoUrl { get; set; } + } +} diff --git a/backend/src/Designer/Models/AttributeMatchV2.cs b/backend/src/Designer/Models/AttributeMatchV2.cs new file mode 100644 index 00000000000..a6facac1027 --- /dev/null +++ b/backend/src/Designer/Models/AttributeMatchV2.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Altinn.Studio.Designer.Models +{ + /// + /// This model describes a pair of AttributeId and AttributeValue for use in matching in XACML policies, for instance a resource, a user, a party or an action. + /// + public class AttributeMatchV2 + { + /// + /// Gets or sets the attribute id for the match + /// + [Required] + public required string Type { get; set; } + + /// + /// Gets or sets the attribute value for the match + /// + [Required] + public required string Value { get; set; } + + /// + /// The urn for the attribute + /// + [Required] + public required string Urn { get; set; } + } +} diff --git a/backend/src/Designer/Models/Dto/SubjectResourcesDto.cs b/backend/src/Designer/Models/Dto/SubjectResourcesDto.cs new file mode 100644 index 00000000000..7c5f1fb585f --- /dev/null +++ b/backend/src/Designer/Models/Dto/SubjectResourcesDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Altinn.Studio.Designer.Models.Dto +{ + public class SubjectResourcesDto + { + public List Data { get; set; } + } +} diff --git a/backend/src/Designer/Models/SubjectResources.cs b/backend/src/Designer/Models/SubjectResources.cs new file mode 100644 index 00000000000..1c752bd749b --- /dev/null +++ b/backend/src/Designer/Models/SubjectResources.cs @@ -0,0 +1,21 @@ +#nullable enable +using System.Collections.Generic; + +namespace Altinn.Studio.Designer.Models +{ + /// + /// Defines resources that a given subject have access to + /// + public class SubjectResources + { + /// + /// The subject + /// + public required AttributeMatchV2 Subject { get; set; } + + /// + /// List of resources that the given subject has access to + /// + public required List Resources { get; set; } + } +} diff --git a/backend/src/Designer/Services/Implementation/ResourceRegistryService.cs b/backend/src/Designer/Services/Implementation/ResourceRegistryService.cs index c33bca20689..64323259f54 100644 --- a/backend/src/Designer/Services/Implementation/ResourceRegistryService.cs +++ b/backend/src/Designer/Services/Implementation/ResourceRegistryService.cs @@ -228,7 +228,7 @@ public async Task> GetResources(string env) /// Get resource list /// /// List of all resources - public async Task> GetResourceList(string env, bool includeAltinn2) + public async Task> GetResourceList(string env, bool includeAltinn2, bool includeApps = false) { string endpointUrl; @@ -236,11 +236,11 @@ public async Task> GetResourceList(string env, bool includ //Checks if not tested locally by passing dev as env parameter if (!env.ToLower().Equals("dev")) { - endpointUrl = $"{GetResourceRegistryBaseUrl(env)}{_platformSettings.ResourceRegistryUrl}/resourcelist/?includeApps=false&includeAltinn2={includeAltinn2}"; + endpointUrl = $"{GetResourceRegistryBaseUrl(env)}{_platformSettings.ResourceRegistryUrl}/resourcelist/?includeApps={includeApps}&includeAltinn2={includeAltinn2}"; } else { - endpointUrl = $"{_platformSettings.ResourceRegistryDefaultBaseUrl}{_platformSettings.ResourceRegistryUrl}/resourcelist/?includeApps=false&includeAltinn2={includeAltinn2}"; + endpointUrl = $"{_platformSettings.ResourceRegistryDefaultBaseUrl}{_platformSettings.ResourceRegistryUrl}/resourcelist/?includeApps={includeApps}&includeAltinn2={includeAltinn2}"; } JsonSerializerOptions options = new JsonSerializerOptions @@ -627,6 +627,25 @@ string env return removeResourceAccessListResponse.StatusCode; } + public async Task> GetSubjectResources(List subjects, string env) + { + string resourceRegisterUrl = GetResourceRegistryBaseUrl(env); + string url = $"{resourceRegisterUrl}/resourceregistry/api/v1/resource/bysubjects"; + + string serializedContent = JsonSerializer.Serialize(subjects, _serializerOptions); + using HttpRequestMessage getSubjectResourcesRequest = new HttpRequestMessage() + { + RequestUri = new Uri(url), + Method = HttpMethod.Post, + Content = new StringContent(serializedContent, Encoding.UTF8, "application/json"), + }; + using HttpResponseMessage response = await _httpClient.SendAsync(getSubjectResourcesRequest); + response.EnsureSuccessStatusCode(); + + SubjectResourcesDto responseContent = await response.Content.ReadAsAsync(); + return responseContent.Data; + } + private async Task> GetBrregParties(string url) { HttpResponseMessage enheterResponse = await _httpClient.GetAsync(url); diff --git a/backend/src/Designer/Services/Interfaces/IResourceRegistry.cs b/backend/src/Designer/Services/Interfaces/IResourceRegistry.cs index ddaa59ea3fb..7278abb4d74 100644 --- a/backend/src/Designer/Services/Interfaces/IResourceRegistry.cs +++ b/backend/src/Designer/Services/Interfaces/IResourceRegistry.cs @@ -36,7 +36,7 @@ public interface IResourceRegistry /// Integration point for retrieving the full list of resources /// /// The resource full list of all resources if exists - Task> GetResourceList(string env, bool includeAltinn2); + Task> GetResourceList(string env, bool includeAltinn2, bool includeApps = false); /// /// Get Resource from Altinn 2 service @@ -169,5 +169,7 @@ public interface IResourceRegistry /// Chosen environment /// HTTP status code of the operation. 204 No content if remove was successful Task RemoveResourceAccessList(string org, string resourceId, string listId, string env); + + Task> GetSubjectResources(List subjects, string env); } } diff --git a/backend/src/Designer/TypedHttpClients/AltinnAuthorization/IPolicyOptions.cs b/backend/src/Designer/TypedHttpClients/AltinnAuthorization/IPolicyOptions.cs index 092503d559d..74a237e88fe 100644 --- a/backend/src/Designer/TypedHttpClients/AltinnAuthorization/IPolicyOptions.cs +++ b/backend/src/Designer/TypedHttpClients/AltinnAuthorization/IPolicyOptions.cs @@ -10,5 +10,7 @@ public interface IPolicyOptions public Task> GetActionOptions(CancellationToken cancellationToken = default); public Task> GetSubjectOptions(CancellationToken cancellationToken = default); + + public Task> GetAccessPackageOptions(CancellationToken cancellationToken = default); } } diff --git a/backend/src/Designer/TypedHttpClients/AltinnAuthorization/PolicyOptionsClient.cs b/backend/src/Designer/TypedHttpClients/AltinnAuthorization/PolicyOptionsClient.cs index 58be87e2475..ffe4fc4cbdc 100644 --- a/backend/src/Designer/TypedHttpClients/AltinnAuthorization/PolicyOptionsClient.cs +++ b/backend/src/Designer/TypedHttpClients/AltinnAuthorization/PolicyOptionsClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -13,6 +14,7 @@ public class PolicyOptionsClient : IPolicyOptions { private readonly HttpClient _client; private readonly ILogger _logger; + private readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions() { PropertyNameCaseInsensitive = true, }; public PolicyOptionsClient(HttpClient httpClient, ILogger logger) { @@ -20,6 +22,27 @@ public PolicyOptionsClient(HttpClient httpClient, ILogger l _logger = logger; } + public async Task> GetAccessPackageOptions(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // Temp location. Will be moved to CDN + string url = "https://raw.githubusercontent.com/Altinn/altinn-studio-docs/master/content/authorization/architecture/resourceregistry/accesspackages_hier.json"; + + List accessPackageOptions; + + try + { + HttpResponseMessage response = await _client.GetAsync(url, cancellationToken); + string accessPackageOptionsString = await response.Content.ReadAsStringAsync(cancellationToken); + accessPackageOptions = JsonSerializer.Deserialize>(accessPackageOptionsString, _serializerOptions); + return accessPackageOptions; + } + catch (Exception ex) + { + throw new Exception($"Something went wrong when retrieving Action options", ex); + } + } + public async Task> GetActionOptions(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/backend/tests/Designer.Tests/Controllers/PolicyController/GetAccessPackageOptionsTests.cs b/backend/tests/Designer.Tests/Controllers/PolicyController/GetAccessPackageOptionsTests.cs new file mode 100644 index 00000000000..99492b4b1bf --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/PolicyController/GetAccessPackageOptionsTests.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Designer.Tests.Controllers.ApiTests; +using Designer.Tests.Utils; +using Microsoft.AspNetCore.Mvc.Testing; +using PolicyAdmin.Models; +using Xunit; + +namespace Designer.Tests.Controllers.PolicyControllerTests +{ + public class GetAccessPackageOptionsTests : DesignerEndpointsTestsBase, IClassFixture> + { + private readonly string _versionPrefix = "designer/api"; + + public GetAccessPackageOptionsTests(WebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task Get_AccessPackageOptions() + { + var targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest("ttd", "ttd-resources", "testUser", targetRepository); + + string dataPathWithData = $"{_versionPrefix}/ttd/{targetRepository}/policy/accesspackageoptions"; + List accessPackageOptions; + using (HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, dataPathWithData)) + { + HttpResponseMessage response = await HttpClient.SendAsync(httpRequestMessage); + response.EnsureSuccessStatusCode(); + string responseBody = await response.Content.ReadAsStringAsync(); + + accessPackageOptions = JsonSerializer.Deserialize>(responseBody, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + + Assert.NotNull(accessPackageOptions); + } + } +} diff --git a/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetAltinn2LinkServicesTests.cs b/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetAltinn2LinkServicesTests.cs index 05ce4fd9eb2..944ca6d7638 100644 --- a/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetAltinn2LinkServicesTests.cs +++ b/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetAltinn2LinkServicesTests.cs @@ -37,7 +37,7 @@ public async Task GetFilteredLinkServices() ExternalServiceCode = "Test2", ExternalServiceEditionCode = 123 }); - ResourceRegistryMock.Setup(r => r.GetResourceList(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + ResourceRegistryMock.Setup(r => r.GetResourceList(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); Altinn2MetadataClientMock.Setup(r => r.AvailableServices(It.IsAny(), It.IsAny())).ReturnsAsync(services); // Act diff --git a/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetServicesForAccessPackageTests.cs b/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetServicesForAccessPackageTests.cs new file mode 100644 index 00000000000..6637c548d8d --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/ResourceAdminController/GetServicesForAccessPackageTests.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Moq; +using Xunit; + +namespace Designer.Tests.Controllers.ResourceAdminController +{ + public class GetServicesForAccessPackage : ResourceAdminControllerTestsBaseClass, IClassFixture> + { + + public GetServicesForAccessPackage(WebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetServicesForAccessPackage_Ok() + { + // Arrange + string uri = $"designer/api/accesspackageservices/urn:altinn:accesspackage:akvakultur/at22"; + using (HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri)) + { + ResourceRegistryMock.Setup(r => r.GetSubjectResources(It.IsAny>(), It.IsAny())).ReturnsAsync([]); + ResourceRegistryMock.Setup(r => r.GetResourceList(It.IsAny(), false, true)).ReturnsAsync([]); + + // Act + using HttpResponseMessage res = await HttpClient.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + } + } +} diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/PolicyTab/PolicyTab.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/PolicyTab/PolicyTab.tsx index 3361c8a1352..c0d5849293b 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/PolicyTab/PolicyTab.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/PolicyTab/PolicyTab.tsx @@ -13,6 +13,7 @@ import { ErrorMessage } from '@digdir/designsystemet-react'; import { LoadingTabData } from '../../LoadingTabData'; import { TabDataError } from '../../TabDataError'; import { + useResourceAccessPackagesQuery, useResourcePolicyActionsQuery, useResourcePolicySubjectsQuery, } from 'app-shared/hooks/queries'; @@ -42,8 +43,14 @@ export const PolicyTab = (): ReactNode => { const { mutate: updateAppPolicyMutation } = useAppPolicyMutation(org, app); + const { + status: accessPackageStatus, + data: accessPackageData, + error: accessPackageError, + } = useResourceAccessPackagesQuery(org, app); + const displayContent = () => { - switch (mergeQueryStatuses(policyStatus, actionStatus, subjectStatus)) { + switch (mergeQueryStatuses(policyStatus, actionStatus, subjectStatus, accessPackageStatus)) { case 'pending': { return ; } @@ -53,6 +60,7 @@ export const PolicyTab = (): ReactNode => { {policyError && {policyError.message}} {actionError && {actionError.message}} {subjectError && {subjectError.message}} + {accessPackageError && {accessPackageError.message}} ); } @@ -75,6 +83,7 @@ export const PolicyTab = (): ReactNode => { policy={policyData} actions={mergedActionList} subjects={mergedSubjectList} + accessPackages={accessPackageData} onSave={updateAppPolicyMutation} showAllErrors usageType='app' diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 5f05cb4ea54..bf720fa3ba6 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -592,6 +592,17 @@ "overview.reset_repo_confirm_info": "Du sletter nå alle endringer du har gjort på {{repositoryName}}. Du kan ikke gjenopprette dem.", "overview.reset_repo_confirm_repo_name": "Skriv inn navnet på appen for å bekrefte at du vil slette", "overview.reset_repo_loading": "Sletter...", + "policy_editor.access_package_add": "Legg til tilgangspakke {{packageName}}", + "policy_editor.access_package_all_packages": "Alle tilgangspakker", + "policy_editor.access_package_chosen_packages": "Valgte tilgangspakker", + "policy_editor.access_package_header": "Tilgangspakker", + "policy_editor.access_package_loading_services": "Laster tjenester", + "policy_editor.access_package_no_services": "Denne tilgangspakken inneholder ingen tjenester enda", + "policy_editor.access_package_remove": "Fjern tilgangspakke {{packageName}}", + "policy_editor.access_package_search": "Søk i tilgangspakker", + "policy_editor.access_package_services": "Tjenester i denne tilgangspakken:", + "policy_editor.access_package_warning_body": "Altinn-rollene fases snart ut, og da vil rollene som er lagt til ikke lenger være gyldige. Du må derfor legge til minst én tilgangspakke for å unngå at regelen blir ugyldig.", + "policy_editor.access_package_warning_header": "Tilgangspakker tar over for Altinn-rollene", "policy_editor.action_complete": "Bekreft mottatt tjenesteeier", "policy_editor.action_confirm": "Bekreft", "policy_editor.action_delete": "Slett", diff --git a/frontend/packages/policy-editor/src/PolicyEditor.tsx b/frontend/packages/policy-editor/src/PolicyEditor.tsx index 3cf74cafe0b..3fc3b40a646 100644 --- a/frontend/packages/policy-editor/src/PolicyEditor.tsx +++ b/frontend/packages/policy-editor/src/PolicyEditor.tsx @@ -20,11 +20,13 @@ import { useTranslation } from 'react-i18next'; import { SecurityLevelSelect } from './components/SecurityLevelSelect'; import { PolicyEditorContextProvider } from './contexts/PolicyEditorContext'; import { PolicyCardRules } from './components/PolicyCardRules'; +import type { PolicyAccessPackageAreaGroup } from 'app-shared/types/PolicyAccessPackages'; export type PolicyEditorProps = { policy: Policy; actions: PolicyAction[]; subjects: PolicySubject[]; + accessPackages?: PolicyAccessPackageAreaGroup[]; resourceId?: string; onSave: (policy: Policy) => void; // MAYBE MOVE TO CONTEXT showAllErrors: boolean; @@ -35,6 +37,7 @@ export const PolicyEditor = ({ policy, actions, subjects, + accessPackages, resourceId, onSave, showAllErrors, @@ -75,6 +78,7 @@ export const PolicyEditor = ({ setPolicyRules={setPolicyRules} actions={actions} subjects={subjects} + accessPackages={accessPackages ?? []} usageType={usageType} resourceType={resourceType} showAllErrors={showAllErrors} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.module.css b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.module.css new file mode 100644 index 00000000000..a829571d9b7 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.module.css @@ -0,0 +1,10 @@ +.accordionIcon { + font-size: var(--fds-spacing-7); + color: var(--fds-semantic-text-neutral-default); +} + +.iconContainer { + background-color: var(--brand-default-third-100); + border-radius: var(--fds-border_radius-small); + padding: var(--fds-sizing-2); +} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.test.tsx new file mode 100644 index 00000000000..d1c192ac896 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AllAccessPackages, type AllAccessPackagesProps } from './AllAccessPackages'; +import type { + PolicyAccessPackage, + PolicyAccessPackageArea, +} from 'app-shared/types/PolicyAccessPackages'; + +const package1Name = 'Package Alpha Skattepenger'; +const package2Name = 'Package Beta Superskatt'; +const package3Name = 'Package Gamma'; +const package1: PolicyAccessPackage = { + id: 'package1', + urn: 'urn:package1', + name: package1Name, + description: 'First package', +}; +const package2: PolicyAccessPackage = { + id: 'package2', + urn: 'urn:package2', + name: package2Name, + description: 'Second package', +}; +const package3: PolicyAccessPackage = { + id: 'package3', + urn: 'urn:package3', + name: 'Package Gamma', + description: 'Third package', +}; + +const area1Name = 'Area 1'; +const area2Name = 'Area 2'; +const groupedAccessPackagesByArea: PolicyAccessPackageArea[] = [ + { + id: 'area1', + name: area1Name, + urn: 'urn:area1', + description: '', + icon: 'BankNote', + areaGroup: '', + packages: [package1, package2], + }, + { + id: 'area2', + name: area2Name, + urn: 'urn:area2', + description: '', + icon: '', + areaGroup: '', + packages: [package3], + }, +]; + +const defaultProps = { + chosenAccessPackages: [], + accessPackagesToRender: groupedAccessPackagesByArea, + searchValue: '', + handleSelectAccessPackage: jest.fn(), +}; + +describe('AllAccessPackages', () => { + it('should render each package with search hits when searching', () => { + renderAllAccessPackages({ searchValue: 'skatt' }); + + expect(screen.getByText(package1Name)).toBeInTheDocument(); + expect(screen.getByText(package2Name)).toBeInTheDocument(); + }); + + it('should not render any access packages when not expanded', () => { + renderAllAccessPackages(); + + expect(screen.queryByText(package1Name)).not.toBeInTheDocument(); + expect(screen.queryByText(package2Name)).not.toBeInTheDocument(); + expect(screen.queryByText(package3Name)).not.toBeInTheDocument(); + }); + + it('should render PolicyAccordion components for each area', () => { + renderAllAccessPackages(); + + expect(screen.getByText(area1Name)).toBeInTheDocument(); + expect(screen.getByText(area2Name)).toBeInTheDocument(); + }); +}); + +const renderAllAccessPackages = (props: Partial = {}) => { + render(); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.tsx new file mode 100644 index 00000000000..6ccaca57383 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/AllAccessPackages.tsx @@ -0,0 +1,51 @@ +import React, { type ReactElement } from 'react'; +import cn from 'classnames'; +import { PolicyAccessPackageAccordion } from '../PolicyAccessPackageAccordion'; +import { PolicyAccordion } from '../PolicyAccordion'; +import { isAccessPackageSelected } from '../policyAccessPackageUtils'; +import type { PolicyAccessPackageArea } from 'app-shared/types/PolicyAccessPackages'; +import classes from './AllAccessPackages.module.css'; +// import all icons from StudioIcons. This is because access package area icons are defined in the json +// we load, and we do not know which icons that is (only that the icons are present in StudioIcons). +// this will be changed later in early 2025, when we will use specific icons for access package areas +import * as StudioIcons from '@studio/icons'; + +export type AllAccessPackagesProps = { + chosenAccessPackages: string[]; + accessPackagesToRender: PolicyAccessPackageArea[]; + searchValue: string; + handleSelectAccessPackage: (accessPackageUrn: string) => void; +}; +export const AllAccessPackages = ({ + chosenAccessPackages, + accessPackagesToRender, + searchValue, + handleSelectAccessPackage, +}: AllAccessPackagesProps): ReactElement[] => { + return accessPackagesToRender.map((area) => ( + } + title={area.name} + subTitle={area.description} + defaultOpen={!!searchValue} + > + {area.packages.map((accessPackage) => ( + + ))} + + )); +}; + +type PolicyAccordionIconProps = { icon: string }; +const PolicyAccordionIcon = ({ icon }: PolicyAccordionIconProps): ReactElement => { + const IconComponent = Object.keys(StudioIcons).includes(icon) + ? StudioIcons[icon] + : StudioIcons.PackageIcon; + return ; +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/index.ts new file mode 100644 index 00000000000..2ee688e247c --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/AllAccessPackages/index.ts @@ -0,0 +1 @@ +export { AllAccessPackages } from './AllAccessPackages'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/ChosenAccessPackages.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/ChosenAccessPackages.test.tsx new file mode 100644 index 00000000000..c0c0d98f5ae --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/ChosenAccessPackages.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ChosenAccessPackages, type ChosenAccessPackagesProps } from './ChosenAccessPackages'; +import type { + PolicyAccessPackage, + PolicyAccessPackageArea, +} from 'app-shared/types/PolicyAccessPackages'; + +const package1: PolicyAccessPackage = { + id: 'package1', + urn: 'urn:package1', + name: 'Package Alpha', + description: 'First package', +}; +const package2: PolicyAccessPackage = { + id: 'package2', + urn: 'urn:package2', + name: 'Package Beta', + description: 'Second package', +}; +const package3: PolicyAccessPackage = { + id: 'package3', + urn: 'urn:package3', + name: 'Package Gamma', + description: 'Third package', +}; + +const groupedAccessPackagesByArea: PolicyAccessPackageArea[] = [ + { + id: 'area1', + name: 'Area 1', + urn: 'urn:area1', + description: '', + icon: '', + areaGroup: '', + packages: [package1, package2], + }, + { + id: 'area2', + name: 'Area 2', + urn: 'urn:area2', + description: '', + icon: '', + areaGroup: '', + packages: [package3], + }, +]; + +const defaultProps = { + chosenAccessPackages: [], + groupedAccessPackagesByArea: groupedAccessPackagesByArea, + handleSelectAccessPackage: jest.fn(), +}; + +describe('ChosenAccessPackages', () => { + it('should render chosen access packages when chosen access packages is not empty', () => { + const chosenAccessPackages = [package1.urn, package3.urn]; + + renderChosenAccessPackages({ chosenAccessPackages }); + + expect(screen.getByText(package1.name)).toBeInTheDocument(); + expect(screen.getByText(package3.name)).toBeInTheDocument(); + }); + + it('should render null when chosen access packages is empty', () => { + renderChosenAccessPackages(); + + expect( + screen.queryByText('policy_editor.access_package_chosen_packages'), + ).not.toBeInTheDocument(); + }); +}); + +const renderChosenAccessPackages = (props: Partial = {}) => { + render(); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/ChosenAccessPackages.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/ChosenAccessPackages.tsx new file mode 100644 index 00000000000..bb397ba6f9f --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/ChosenAccessPackages.tsx @@ -0,0 +1,51 @@ +import React, { type ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioLabelAsParagraph } from '@studio/components'; +import { PolicyAccessPackageAccordion } from '../PolicyAccessPackageAccordion'; +import { filterAccessPackagesById, flatMapAreaPackageList } from '../policyAccessPackageUtils'; +import type { + PolicyAccessPackage, + PolicyAccessPackageArea, +} from 'app-shared/types/PolicyAccessPackages'; + +export type ChosenAccessPackagesProps = { + chosenAccessPackages: string[]; + groupedAccessPackagesByArea: PolicyAccessPackageArea[]; + handleSelectAccessPackage: (accessPackageUrn: string) => void; +}; +export const ChosenAccessPackages = ({ + chosenAccessPackages, + groupedAccessPackagesByArea, + handleSelectAccessPackage, +}: ChosenAccessPackagesProps): ReactElement => { + const { t } = useTranslation(); + + const flatMappedAreaList: PolicyAccessPackage[] = flatMapAreaPackageList( + groupedAccessPackagesByArea, + ); + const selectedAccessPackageList: PolicyAccessPackage[] = filterAccessPackagesById( + flatMappedAreaList, + chosenAccessPackages, + ); + + if (chosenAccessPackages.length > 0) { + return ( + <> + + {t('policy_editor.access_package_chosen_packages')} + + {selectedAccessPackageList.map((accessPackage: PolicyAccessPackage) => { + return ( + + ); + })} + + ); + } + return null; +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/index.ts new file mode 100644 index 00000000000..c557fae6244 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/ChosenAccessPackages/index.ts @@ -0,0 +1 @@ +export { ChosenAccessPackages } from './ChosenAccessPackages'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.module.css b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.module.css new file mode 100644 index 00000000000..278d918f12d --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.module.css @@ -0,0 +1,3 @@ +.accessPackageAccordion { + background-color: var(--brand-default-third-100); +} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.test.tsx new file mode 100644 index 00000000000..bbf8b5a28c9 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { + PolicyAccessPackageAccordion, + type PolicyAccessPackageAccordionProps, +} from './PolicyAccessPackageAccordion'; + +const defaultProps = { + accessPackage: { + id: 'urn:altinn:accesspackage:sjofart', + urn: 'urn:altinn:accesspackage:sjofart', + name: 'Sjøfart', + description: '', + }, + isChecked: false, + handleSelectChange: jest.fn(), +}; + +describe('PolicyAccessPackageAccordion', () => { + it('should show accordion for accesspackage', () => { + renderPolicyAccessPackageAccordion(); + + expect(screen.getByText(defaultProps.accessPackage.name)).toBeInTheDocument(); + }); +}); + +const renderPolicyAccessPackageAccordion = ( + props: Partial = {}, +) => { + render(); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.tsx new file mode 100644 index 00000000000..a446346fca1 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordion.tsx @@ -0,0 +1,36 @@ +import React, { type ReactElement } from 'react'; +import classes from './PolicyAccessPackageAccordion.module.css'; +import { PolicyAccordion } from '../PolicyAccordion'; +import { PolicyAccessPackageAccordionContent } from './PolicyAccessPackageAccordionContent'; +import { PolicyAccessPackageAccordionCheckbox } from './PolicyAccessPackageAccordionCheckbox'; +import type { PolicyAccessPackage } from 'app-shared/types/PolicyAccessPackages'; + +export type PolicyAccessPackageAccordionProps = { + accessPackage: PolicyAccessPackage; + isChecked: boolean; + handleSelectChange: (accessPackageUrn: string) => void; +}; + +export const PolicyAccessPackageAccordion = ({ + accessPackage, + isChecked, + handleSelectChange, +}: PolicyAccessPackageAccordionProps): ReactElement => { + return ( +
+ + } + > + + +
+ ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.module.css b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.module.css new file mode 100644 index 00000000000..32d895091c0 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.module.css @@ -0,0 +1,3 @@ +.accordionCheckbox { + padding: 0 var(--fds-spacing-4); +} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.test.tsx new file mode 100644 index 00000000000..61e68f07f80 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { + PolicyAccessPackageAccordionCheckbox, + type PolicyAccessPackageAccordionCheckboxProps, +} from './PolicyAccessPackageAccordionCheckbox'; + +const defaultProps = { + accessPackage: { + id: '1345', + name: 'Lufttransport', + description: 'Luft', + urn: 'urn', + }, + isChecked: false, + handleSelectChange: jest.fn(), +}; + +describe('PolicyAccessPackageAccordionCheckbox', () => { + it('should show checked text', () => { + renderPolicyAccessPackageAccordionCheckbox(); + + const expectedAddText = textMock('policy_editor.access_package_add', { + packageName: defaultProps.accessPackage.name, + }); + expect(screen.getByLabelText(expectedAddText)).toBeInTheDocument(); + }); + + it('should show unchecked text', () => { + renderPolicyAccessPackageAccordionCheckbox({ isChecked: true }); + + const expectedRemoveText = textMock('policy_editor.access_package_remove', { + packageName: defaultProps.accessPackage.name, + }); + expect(screen.getByLabelText(expectedRemoveText)).toBeInTheDocument(); + }); + + it('should call handleSelectChange when checkbox is checked', async () => { + const user = userEvent.setup(); + const handleSelectChangeFn = jest.fn(); + + renderPolicyAccessPackageAccordionCheckbox({ handleSelectChange: handleSelectChangeFn }); + + const expectedAddText = textMock('policy_editor.access_package_add', { + packageName: defaultProps.accessPackage.name, + }); + const checkbox = screen.getByLabelText(expectedAddText); + await user.click(checkbox); + + expect(handleSelectChangeFn).toHaveBeenCalledWith('urn'); + }); +}); + +const renderPolicyAccessPackageAccordionCheckbox = ( + props: Partial = {}, +) => { + render(); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.tsx new file mode 100644 index 00000000000..879a7a163bf --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/PolicyAccessPackageAccordionCheckbox.tsx @@ -0,0 +1,34 @@ +import React, { type ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classes from './PolicyAccessPackageAccordionCheckbox.module.css'; +import { StudioCheckbox } from '@studio/components'; +import type { PolicyAccessPackage } from 'app-shared/types/PolicyAccessPackages'; + +export type PolicyAccessPackageAccordionCheckboxProps = { + accessPackage: PolicyAccessPackage; + isChecked: boolean; + handleSelectChange: (accessPackageUrn: string) => void; +}; +export const PolicyAccessPackageAccordionCheckbox = ({ + accessPackage, + isChecked, + handleSelectChange, +}: PolicyAccessPackageAccordionCheckboxProps): ReactElement => { + const { t } = useTranslation(); + const checkboxLabel = t( + isChecked ? 'policy_editor.access_package_remove' : 'policy_editor.access_package_add', + { + packageName: accessPackage.name, + }, + ); + + return ( + handleSelectChange(accessPackage.urn)} + aria-label={checkboxLabel} + /> + ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/index.ts new file mode 100644 index 00000000000..53bc72f8650 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionCheckbox/index.ts @@ -0,0 +1 @@ +export { PolicyAccessPackageAccordionCheckbox } from './PolicyAccessPackageAccordionCheckbox'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/PolicyAccessPackageAccordionContent.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/PolicyAccessPackageAccordionContent.test.tsx new file mode 100644 index 00000000000..070b9a68eaf --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/PolicyAccessPackageAccordionContent.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import type { QueryClient } from '@tanstack/react-query'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { PolicyAccessPackageAccordionContent } from './PolicyAccessPackageAccordionContent'; +import type { AccessPackageResource } from 'app-shared/types/PolicyAccessPackages'; + +const resource: AccessPackageResource = { + identifier: 'kravogbetaling', + title: { + nb: 'Krav og betaling', + nn: 'Krav og betaling', + en: 'Krav og betaling', + }, + hasCompetentAuthority: { + name: { + nb: 'Skatteetaten', + nn: 'Skatteetaten', + en: 'Skatteetaten', + }, + organization: '974761076', + orgcode: 'skd', + }, + logoUrl: '', +}; + +describe('PolicyAccessPackageAccordionContent', () => { + afterEach(jest.clearAllMocks); + + it('should show spinner on loading', () => { + renderPolicyAccessPackageAccordionContent(); + + expect( + screen.getByText(textMock('policy_editor.access_package_loading_services')), + ).toBeInTheDocument(); + }); + + it('should show services', async () => { + const getAccessPackageServices = jest + .fn() + .mockImplementation(() => Promise.resolve([resource])); + + renderPolicyAccessPackageAccordionContent({ getAccessPackageServices }); + + expect(await screen.findByText(resource.title.nb)).toBeInTheDocument(); + }); + + it('should show text if package has no connected services', async () => { + renderPolicyAccessPackageAccordionContent(); + + expect( + await screen.findByText(textMock('policy_editor.access_package_no_services')), + ).toBeInTheDocument(); + }); +}); + +const renderPolicyAccessPackageAccordionContent = (queries: Partial = {}) => { + const queryClient: QueryClient = createQueryClientMock(); + const allQueries: ServicesContextProps = { + ...queriesMock, + ...queries, + }; + return render( + + + , + ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/PolicyAccessPackageAccordionContent.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/PolicyAccessPackageAccordionContent.tsx new file mode 100644 index 00000000000..1f50b5a4fb5 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/PolicyAccessPackageAccordionContent.tsx @@ -0,0 +1,35 @@ +import React, { type ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useResourceAccessPackageServicesQuery } from 'app-shared/hooks/queries/useResourceAccessPackageServicesQuery'; +import { StudioParagraph, StudioSpinner } from '@studio/components'; +import { PolicyAccessPackageServices } from '../PolicyAccessPackageServices'; + +type PolicyAccessPackageAccordionContentProps = { accessPackageUrn: string }; + +export const PolicyAccessPackageAccordionContent = ({ + accessPackageUrn, +}: PolicyAccessPackageAccordionContentProps): ReactElement => { + const { t } = useTranslation(); + // Determine enviroment to load resources/apps connected to each access packages from. Option to override this + // value with a localStorage setting is for testing. Valid options are 'at22', 'at23', 'at24', 'tt02' + const accessPackageResourcesEnv = localStorage.getItem('accessPackageResourcesEnv') || 'prod'; + + const { data: services, isLoading } = useResourceAccessPackageServicesQuery( + accessPackageUrn, + accessPackageResourcesEnv, + ); + + const hasServices: boolean = services?.length > 0; + const serviceListIsEmpty: boolean = services?.length === 0; + return ( + <> + {isLoading && ( + + )} + {hasServices && } + {serviceListIsEmpty && ( + {t('policy_editor.access_package_no_services')} + )} + + ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/index.ts new file mode 100644 index 00000000000..354351ee7ae --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageAccordionContent/index.ts @@ -0,0 +1 @@ +export { PolicyAccessPackageAccordionContent } from './PolicyAccessPackageAccordionContent'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.module.css b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.module.css new file mode 100644 index 00000000000..5c128afbe5c --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.module.css @@ -0,0 +1,37 @@ +:root { + --logo-size: var(--fds-sizing-5); +} + +.serviceContainer { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--fds-spacing-2); + background-color: var(--fds-semantic-background-default); + padding: 0 var(--fds-spacing-2); + min-height: var(--fds-spacing-8); + font-size: var(--fds-sizing-3); + box-shadow: var(--fds-shadow-small); +} + +.serviceLabel { + flex: 1; + font-weight: 500; +} + +.serviceContainerHeader { + color: var(--colors-grey-700); + font-size: var(--fds-sizing-3); + padding-top: var(--fds-spacing-2); + border-top: 1px solid var(--fds-semantic-border-neutral-subtle); +} + +.logo { + max-height: var(--logo-size); + max-width: var(--logo-size); +} + +.emptyLogo { + height: var(--logo-size); + width: var(--logo-size); +} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.test.tsx new file mode 100644 index 00000000000..dc5f7766e30 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { + PolicyAccessPackageServices, + type PolicyAccessPackageServicesProps, +} from './PolicyAccessPackageServices'; +import type { AccessPackageResource } from 'app-shared/types/PolicyAccessPackages'; + +const resource: AccessPackageResource = { + identifier: 'kravogbetaling', + title: { + nb: 'Krav og betaling', + nn: 'Krav og betaling', + en: 'Krav og betaling', + }, + hasCompetentAuthority: { + name: { + nb: 'Skatteetaten', + nn: 'Skatteetaten', + en: 'Skatteetaten', + }, + organization: '974761076', + orgcode: 'skd', + }, + logoUrl: '', +}; + +const defaultProps = { + services: [resource], +}; + +describe('PolicyAccessPackageServices', () => { + it('should show list of services', () => { + renderPolicyAccessPackageServices(); + + expect(screen.getByText(resource.title.nb)).toBeInTheDocument(); + }); + + it('should show logo for services', () => { + renderPolicyAccessPackageServices({ + services: [{ ...resource, logoUrl: 'https://altinncdn.no/orgs/skd/skd.png' }], + }); + + expect(screen.getByAltText(resource.hasCompetentAuthority.name.nb)).toBeInTheDocument(); + }); + + it('should show empty container if resource has no logo', () => { + renderPolicyAccessPackageServices(); + + expect(screen.getByTestId('no-service-logo')).toBeInTheDocument(); + }); +}); + +const renderPolicyAccessPackageServices = ( + props: Partial = {}, +) => { + render(); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.tsx new file mode 100644 index 00000000000..d373cdededa --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/PolicyAccessPackageServices.tsx @@ -0,0 +1,52 @@ +import React, { type ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classes from './PolicyAccessPackageServices.module.css'; +import { StudioParagraph } from '@studio/components'; +import type { AccessPackageResource } from 'app-shared/types/PolicyAccessPackages'; + +const selectedLanguage = 'nb'; + +export type PolicyAccessPackageServicesProps = { + services: AccessPackageResource[]; +}; +export const PolicyAccessPackageServices = ({ + services, +}: PolicyAccessPackageServicesProps): ReactElement => { + const { t } = useTranslation(); + + return ( + <> + + {t('policy_editor.access_package_services')} + + {services.map((resource) => ( +
+ +
{resource.title[selectedLanguage]}
+
{resource.hasCompetentAuthority.name[selectedLanguage]}
+
+ ))} + + ); +}; + +type PolicyAccessPackageServiceLogoProps = { + resource: AccessPackageResource; + language: string; +}; +export const PolicyAccessPackageServiceLogo = ({ + resource, + language, +}: PolicyAccessPackageServiceLogoProps): ReactElement => { + if (resource.logoUrl) { + return ( + {resource.hasCompetentAuthority.name[language]} + ); + } + return
; +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/index.ts new file mode 100644 index 00000000000..bcb7456ca9b --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/PolicyAccessPackageServices/index.ts @@ -0,0 +1 @@ +export { PolicyAccessPackageServices } from './PolicyAccessPackageServices'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/index.ts new file mode 100644 index 00000000000..2b6e8787767 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackageAccordion/index.ts @@ -0,0 +1 @@ +export { PolicyAccessPackageAccordion } from './PolicyAccessPackageAccordion'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.module.css b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.module.css new file mode 100644 index 00000000000..19c1122d364 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.module.css @@ -0,0 +1,6 @@ +.accessPackages { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-2); + margin-top: var(--fds-spacing-8); +} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.test.tsx new file mode 100644 index 00000000000..be0389e00cd --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.test.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PolicyEditorContext } from '@altinn/policy-editor/contexts/PolicyEditorContext'; +import { PolicyAccessPackages } from './PolicyAccessPackages'; +import { PolicyRuleContext } from '@altinn/policy-editor/contexts/PolicyRuleContext'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { mockPolicyRuleContextValue } from '../../../../../test/mocks/policyRuleContextMock'; +import { mockPolicyEditorContextValue } from '../../../../../test/mocks/policyEditorContextMock'; +import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import type { + PolicyAccessPackage, + PolicyAccessPackageArea, + PolicyAccessPackageAreaGroup, +} from 'app-shared/types/PolicyAccessPackages'; + +const skattPackage: PolicyAccessPackage = { + id: 'urn:altinn:accesspackage:skatt', + urn: 'urn:altinn:accesspackage:skatt', + name: 'Skatt', + description: '', +}; + +const sjofartPackage: PolicyAccessPackage = { + id: 'urn:altinn:accesspackage:sjofart', + urn: 'urn:altinn:accesspackage:sjofart', + name: 'Sjøfart', + description: '', +}; + +const lufttransportPackage: PolicyAccessPackage = { + id: 'urn:altinn:accesspackage:lufttransport', + urn: 'urn:altinn:accesspackage:lufttransport', + name: 'Lufttransport', + description: '', +}; + +const revisorPackage: PolicyAccessPackage = { + id: 'urn:altinn:accesspackage:revisor', + urn: 'urn:altinn:accesspackage:revisor', + name: 'Revisor', + description: '', +}; + +const accessPackageAreaSkatt: PolicyAccessPackageArea = { + id: 'skatt-area', + urn: 'accesspackage:area:skatt_avgift_regnskap_og_toll', + name: 'Skatt', + description: '', + icon: '', + areaGroup: 'Vanlig', + packages: [skattPackage], +}; + +const accessPackageAreaTransport: PolicyAccessPackageArea = { + id: 'transport-area', + urn: 'accesspackage:area:transport', + name: 'Lagring og transport', + description: '', + icon: 'TruckIcon', + areaGroup: 'Vanlig', + packages: [sjofartPackage, lufttransportPackage], +}; + +const accessPackageAreaOther: PolicyAccessPackageArea = { + id: 'other-area', + urn: 'accesspackage:area:annet', + name: 'Annet', + description: '', + icon: 'TruckIcon', + areaGroup: 'Vanlig', + packages: [revisorPackage], +}; + +const accessPackageAreaGroupVanlig: PolicyAccessPackageAreaGroup = { + id: 'vanlig', + urn: 'accesspackage:areagroup:vanlig', + name: 'Vanlig', + description: 'Mest vanlige pakkenegruppene', + type: 'Organisasjon', + areas: [accessPackageAreaSkatt, accessPackageAreaTransport, accessPackageAreaOther], +}; + +describe('PolicyAccessPackages', () => { + afterEach(jest.clearAllMocks); + + it('should call add service when access package is checked', async () => { + const user = userEvent.setup(); + renderAccessPackages(); + + const accordionButton = screen.getByRole('button', { name: accessPackageAreaTransport.name }); + await user.click(accordionButton); + + const packageCheckbox = screen.getByLabelText( + textMock('policy_editor.access_package_add', { + packageName: sjofartPackage.name, + }), + ); + + await user.click(packageCheckbox); + + expect(packageCheckbox).toBeChecked(); + }); + + it('should call remove service when access package is unchecked', async () => { + const user = userEvent.setup(); + renderAccessPackages(); + + const packageCheckbox = screen.getByLabelText( + textMock('policy_editor.access_package_remove', { + packageName: lufttransportPackage.name, + }), + ); + + await user.click(packageCheckbox); + + expect(packageCheckbox).not.toBeChecked(); + }); + + it('should filter list on search', async () => { + const user = userEvent.setup(); + renderAccessPackages(); + + const searchField = screen.getByLabelText(textMock('policy_editor.access_package_search')); + await user.type(searchField, 'Sjø'); + + expect(screen.getByText('Sjøfart')).toBeInTheDocument(); + }); +}); + +const renderAccessPackages = () => { + const queryClient = createQueryClientMock(); + + return render( + + + + + + + , + ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.tsx new file mode 100644 index 00000000000..416dde5b131 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccessPackages.tsx @@ -0,0 +1,120 @@ +import React, { type ReactElement, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + StudioAlert, + StudioLabelAsParagraph, + StudioParagraph, + StudioTextfield, +} from '@studio/components'; +import { getUpdatedRules } from '../../../../utils/PolicyRuleUtils'; +import { usePolicyEditorContext } from '../../../../contexts/PolicyEditorContext'; +import { usePolicyRuleContext } from '../../../../contexts/PolicyRuleContext'; +import classes from './PolicyAccessPackages.module.css'; +import { + filterAccessPackagesBySearchString, + groupAccessPackagesByArea, + isAccessPackageSelected, +} from './policyAccessPackageUtils'; +import { ChosenAccessPackages } from './ChosenAccessPackages/ChosenAccessPackages'; +import { AllAccessPackages } from './AllAccessPackages/AllAccessPackages'; + +export const PolicyAccessPackages = (): ReactElement => { + const { t } = useTranslation(); + const [searchValue, setSearchValue] = useState(''); + const { policyRules, accessPackages, setPolicyRules, savePolicy } = usePolicyEditorContext(); + const { policyRule } = usePolicyRuleContext(); + + const [chosenAccessPackages, setChosenAccessPackages] = useState( + policyRule.accessPackages, + ); + + const groupedAccessPackagesByArea = useMemo(() => { + return groupAccessPackagesByArea(accessPackages); + }, [accessPackages]); + + const handleSelectAccessPackage = (packageUrn: string): void => { + const isChecked = isAccessPackageSelected(packageUrn, chosenAccessPackages); + + if (isChecked) { + handleDeselectAccessPackage(packageUrn); + } else { + handleSelectNewAccessPackage(packageUrn); + } + }; + + const handleDeselectAccessPackage = (packageUrn: string): void => { + setChosenAccessPackages((oldUrns) => oldUrns.filter((urn) => urn !== packageUrn)); + const urnsToSave = policyRule.accessPackages.filter((x) => x !== packageUrn); + handleAccessPackageChange(urnsToSave); + }; + + const handleSelectNewAccessPackage = (packageUrn: string): void => { + setChosenAccessPackages((oldUrns) => [...oldUrns, packageUrn]); + const urnsToSave = [...policyRule.accessPackages, packageUrn]; + handleAccessPackageChange(urnsToSave); + }; + + const handleAccessPackageChange = (newSelectedAccessPackageUrns: string[]): void => { + const updatedRules = getUpdatedRules( + { + ...policyRule, + accessPackages: newSelectedAccessPackageUrns, + }, + policyRule.ruleId, + policyRules, + ); + setPolicyRules(updatedRules); + savePolicy(updatedRules); + }; + + const handleSearch = (event: React.ChangeEvent): void => { + setSearchValue(event.target.value); + }; + + const accessPackagesToRender = filterAccessPackagesBySearchString( + groupedAccessPackagesByArea, + searchValue, + ); + + return ( +
+ + + {t('policy_editor.access_package_warning_header')} + + + {t('policy_editor.access_package_warning_body')} + + + + {t('policy_editor.access_package_header')} + + + + {t('policy_editor.access_package_all_packages')} + + + {t('policy_editor.access_package_search')} + + } + hideLabel + placeholder={t('policy_editor.access_package_search')} + size='small' + value={searchValue} + onChange={handleSearch} + /> + +
+ ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.module.css b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.module.css new file mode 100644 index 00000000000..d6555299787 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.module.css @@ -0,0 +1,36 @@ +.accordion { + box-shadow: var(--fds-shadow-small); +} + +.accordionHeader { + display: flex; + align-items: center; +} + +.accordionButton { + width: 100%; + display: flex; + gap: var(--fds-spacing-2); + align-items: center; +} + +.accordionIcon { + font-size: var(--fds-spacing-7); + color: var(--fds-semantic-text-neutral-default); +} + +.accordionContent { + padding: var(--fds-spacing-4); + display: flex; + flex-direction: column; + gap: var(--fds-spacing-2); +} + +.accordionTitle { + text-align: left; +} + +.accordionSubTitle { + color: var(--colors-grey-700); + font-size: var(--fds-sizing-3); +} diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.test.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.test.tsx new file mode 100644 index 00000000000..bf5791e22f0 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PolicyAccordion, type PolicyAccordionProps } from './PolicyAccordion'; + +const buttonText = 'Test'; +const defaultProps = { + title: buttonText, + subTitle: '', +}; +const childElementText = 'TEST CHILD ELEMENT'; + +describe('PolicyAccordion', () => { + it('should show children when expanded', async () => { + const user = userEvent.setup(); + + renderPolicyAccordion(); + + const accordionButton = screen.getByRole('button', { name: buttonText }); + await user.click(accordionButton); + + expect(screen.getByText(childElementText)).toBeInTheDocument(); + }); +}); + +const renderPolicyAccordion = (props: Partial = {}) => { + render( + +
{childElementText}
+
, + ); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.tsx new file mode 100644 index 00000000000..1a2b4cbcb48 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/PolicyAccordion.tsx @@ -0,0 +1,64 @@ +import React, { type ReactNode, type ReactElement, useId, useState } from 'react'; +import { StudioButton, StudioLabelAsParagraph } from '@studio/components'; +import { ChevronUpIcon, ChevronDownIcon } from '@studio/icons'; +import classes from './PolicyAccordion.module.css'; + +export type PolicyAccordionProps = { + icon?: ReactElement; + title: string; + subTitle: string; + extraHeaderContent?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +}; + +export const PolicyAccordion = ({ + icon, + title, + subTitle, + extraHeaderContent, + defaultOpen, + children, +}: PolicyAccordionProps): ReactElement => { + const contentId = useId(); + const initialExpandedState: boolean = defaultOpen || false; + const [isExpanded, setIsExpanded] = useState(initialExpandedState); + + const handleToggleExpanded = (): void => { + setIsExpanded((oldIsExpanded) => !oldIsExpanded); + }; + + return ( +
+
+ +
+ {icon} +
+ {title} +
{subTitle}
+
+
+ +
+ {extraHeaderContent} +
+ {isExpanded && ( +
+ {children} +
+ )} +
+ ); +}; + +const PolicyAccordionIcon = ({ isExpanded }: { isExpanded: boolean }): ReactNode => { + const IconComponent = isExpanded ? ChevronUpIcon : ChevronDownIcon; + return ; +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/index.ts new file mode 100644 index 00000000000..915d9c68a38 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/PolicyAccordion/index.ts @@ -0,0 +1 @@ +export { PolicyAccordion } from './PolicyAccordion'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/index.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/index.ts new file mode 100644 index 00000000000..a038187fa64 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/index.ts @@ -0,0 +1 @@ +export { PolicyAccessPackages } from './PolicyAccessPackages'; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/policyAccessPackageUtils.test.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/policyAccessPackageUtils.test.ts new file mode 100644 index 00000000000..1cf05accf15 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/policyAccessPackageUtils.test.ts @@ -0,0 +1,134 @@ +import type { PolicyAccessPackageArea } from 'app-shared/types/PolicyAccessPackages'; +import { + filterAccessPackagesBySearchString, + filterAccessPackagesById, + groupAccessPackagesByArea, + flatMapAreaPackageList, + isAccessPackageSelected, +} from './policyAccessPackageUtils'; + +const area1: PolicyAccessPackageArea = { + id: 'area1', + name: 'Area 1', + urn: 'urn:area1', + description: '', + icon: '', + areaGroup: '', + packages: [ + { + id: 'package1', + urn: 'urn:package1', + name: 'Package Alpha', + description: 'First package', + }, + { + id: 'package2', + urn: 'urn:package2', + name: 'Package Beta', + description: 'Second package', + }, + ], +}; + +const area2: PolicyAccessPackageArea = { + id: 'area2', + name: 'Area 2', + urn: 'urn:area2', + description: '', + icon: '', + areaGroup: '', + packages: [ + { + id: 'package3', + urn: 'urn:package3', + name: 'Package Gamma', + description: 'Third package', + }, + ], +}; + +const areas = [area1, area2]; + +describe('policyAccessPackageUtils', () => { + describe('isAccessPackageSelected', () => { + it('returns true if the access package URN is in the list', () => { + expect(isAccessPackageSelected('urn:package1', ['urn:package1', 'urn:package2'])).toBe(true); + }); + + it('returns false if the access package URN is not in the list', () => { + expect(isAccessPackageSelected('urn:package3', ['urn:package1', 'urn:package2'])).toBe(false); + }); + }); + + describe('filterAccessPackagesById', () => { + const accessPackages = [ + { id: 'package1', urn: 'urn:package1', name: 'Package 1', description: '' }, + { id: 'package2', urn: 'urn:package2', name: 'Package 2', description: '' }, + ]; + + it('filters access packages by chosen URNs', () => { + const result = filterAccessPackagesById(accessPackages, ['urn:package1']); + expect(result).toEqual([accessPackages[0]]); + }); + + it('returns an empty array if no URNs match', () => { + const result = filterAccessPackagesById(accessPackages, ['urn:package3']); + expect(result).toEqual([]); + }); + }); + + describe('filterAccessPackagesBySearchString', () => { + it('filters packages by search string matching name or description', () => { + const result = filterAccessPackagesBySearchString(areas, 'Alpha'); + expect(result).toEqual([ + { + ...area1, + packages: [area1.packages[0]], + }, + ]); + }); + + it('returns all areas if search string is empty', () => { + const result = filterAccessPackagesBySearchString(areas, ''); + expect(result).toEqual(areas); + }); + + it('returns an empty array if no packages match', () => { + const result = filterAccessPackagesBySearchString(areas, 'Nonexistent'); + expect(result).toEqual([]); + }); + }); + + describe('groupAccessPackagesByArea', () => { + const groups = [ + { + id: 'group1', + name: 'Area 1', + urn: 'urn:group1', + description: '', + type: '', + areas: [area1], + }, + { + id: 'group2', + name: 'Area 2', + urn: 'urn:group2', + description: '', + type: '', + areas: [area2], + }, + ]; + + it('groups areas from all area groups', () => { + const result = groupAccessPackagesByArea(groups); + expect(result).toEqual([area1, area2]); + }); + }); + + describe('flatMapAreaPackageList', () => { + it('flattens packages from all areas into a single array', () => { + const result = flatMapAreaPackageList(areas); + expect(result).toEqual([area1.packages[0], area1.packages[1], area2.packages[0]]); + }); + }); +}); diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/policyAccessPackageUtils.ts b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/policyAccessPackageUtils.ts new file mode 100644 index 00000000000..3bf1e13e648 --- /dev/null +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyAccessPackages/policyAccessPackageUtils.ts @@ -0,0 +1,65 @@ +import type { + PolicyAccessPackage, + PolicyAccessPackageArea, + PolicyAccessPackageAreaGroup, +} from 'app-shared/types/PolicyAccessPackages'; + +const isStringMatch = (matchString: string, searchString: string): boolean => { + return matchString.toLowerCase().includes(searchString.toLowerCase()); +}; + +const filterAreaPackagesBySearchString = ( + area: PolicyAccessPackageArea, + searchString: string, +): PolicyAccessPackage[] => { + return area.packages.filter( + (pack) => + !searchString || + isStringMatch(pack.name, searchString) || + isStringMatch(pack.description, searchString), + ); +}; + +export const filterAccessPackagesBySearchString = ( + accessPackageAreas: PolicyAccessPackageArea[], + searchString: string, +): PolicyAccessPackageArea[] => { + return accessPackageAreas.reduce( + (areas: PolicyAccessPackageArea[], area): PolicyAccessPackageArea[] => { + const matchingPackages = filterAreaPackagesBySearchString(area, searchString); + if (matchingPackages.length > 0) { + return [...areas, { ...area, packages: matchingPackages }]; + } + return areas; + }, + [], + ); +}; + +export const groupAccessPackagesByArea = ( + accessPackageAreaGroups: PolicyAccessPackageAreaGroup[], +): PolicyAccessPackageArea[] => { + return accessPackageAreaGroups.flatMap((group: PolicyAccessPackageAreaGroup) => group.areas); +}; + +export const flatMapAreaPackageList = ( + areaList: PolicyAccessPackageArea[], +): PolicyAccessPackage[] => { + return areaList.flatMap((area: PolicyAccessPackageArea) => area.packages); +}; + +export const filterAccessPackagesById = ( + accessPackageList: PolicyAccessPackage[], + chosenAccessPackageUrns: string[], +): PolicyAccessPackage[] => { + return accessPackageList.filter((accessPackage: PolicyAccessPackage) => + isAccessPackageSelected(accessPackage.urn, chosenAccessPackageUrns), + ); +}; + +export const isAccessPackageSelected = ( + accessPackageUrn: string, + chosenAccessPackageUrns: string[], +): boolean => { + return chosenAccessPackageUrns.includes(accessPackageUrn); +}; diff --git a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyRule.tsx b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyRule.tsx index 11c34063201..4a306fc9e79 100644 --- a/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyRule.tsx +++ b/frontend/packages/policy-editor/src/components/PolicyCardRules/PolicyRule/PolicyRule.tsx @@ -12,6 +12,8 @@ import { PolicyRuleErrorMessage } from './PolicyRuleErrorMessage'; import { getNewRuleId } from '../../../utils'; import { usePolicyEditorContext } from '../../../contexts/PolicyEditorContext'; import { ObjectUtils } from '@studio/pure-functions'; +import { PolicyAccessPackages } from './PolicyAccessPackages'; +import { FeatureFlag, shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; export type PolicyRuleProps = { policyRule: PolicyRuleCard; @@ -83,6 +85,7 @@ export const PolicyRule = ({ + {shouldDisplayFeature(FeatureFlag.AccessPackages) && } {showErrors && } diff --git a/frontend/packages/policy-editor/src/contexts/PolicyEditorContext/PolicyEditorContext.tsx b/frontend/packages/policy-editor/src/contexts/PolicyEditorContext/PolicyEditorContext.tsx index a93948441c1..692375d87d0 100644 --- a/frontend/packages/policy-editor/src/contexts/PolicyEditorContext/PolicyEditorContext.tsx +++ b/frontend/packages/policy-editor/src/contexts/PolicyEditorContext/PolicyEditorContext.tsx @@ -1,11 +1,13 @@ import React, { createContext, useContext } from 'react'; import type { PolicyAction, PolicyEditorUsage, PolicyRuleCard, PolicySubject } from '../../types'; +import type { PolicyAccessPackageAreaGroup } from 'app-shared/types/PolicyAccessPackages'; export type PolicyEditorContextProps = { policyRules: PolicyRuleCard[]; setPolicyRules: React.Dispatch>; actions: PolicyAction[]; subjects: PolicySubject[]; + accessPackages: PolicyAccessPackageAreaGroup[]; usageType: PolicyEditorUsage; resourceType: string; resourceId: string; diff --git a/frontend/packages/policy-editor/src/types/index.ts b/frontend/packages/policy-editor/src/types/index.ts index aaede8e41ac..2ea89b6760a 100644 --- a/frontend/packages/policy-editor/src/types/index.ts +++ b/frontend/packages/policy-editor/src/types/index.ts @@ -2,6 +2,7 @@ export interface PolicyRuleCard { ruleId: string; description: string; subject: string[]; + accessPackages?: string[]; actions: string[]; resources: PolicyRuleResource[][]; } @@ -29,6 +30,7 @@ export interface PolicyRule { description: string; subject: string[]; actions: string[]; + accessPackages?: string[]; resources: string[][]; } diff --git a/frontend/packages/policy-editor/src/utils/index.ts b/frontend/packages/policy-editor/src/utils/index.ts index cc74d1513ca..33b9eec82bc 100644 --- a/frontend/packages/policy-editor/src/utils/index.ts +++ b/frontend/packages/policy-editor/src/utils/index.ts @@ -16,6 +16,7 @@ export const emptyPolicyRule: PolicyRuleCard = { resources: [], actions: [], subject: [], + accessPackages: [], description: '', }; @@ -63,6 +64,7 @@ export const mapPolicyRulesBackendObjectToPolicyRuleCard = ( actions: r.actions, description: r.description, subject: subjectIds, + accessPackages: r.accessPackages, resources: mappedResources, }; }); @@ -114,6 +116,7 @@ export const mapPolicyRuleToPolicyRuleBackendObject = ( description: policyRule.description, subject: subject, actions: policyRule.actions, + accessPackages: policyRule.accessPackages, resources: resources, }; }; diff --git a/frontend/packages/policy-editor/test/mocks/policyEditorContextMock.ts b/frontend/packages/policy-editor/test/mocks/policyEditorContextMock.ts index 986a74e0ee8..67980a9d7b6 100644 --- a/frontend/packages/policy-editor/test/mocks/policyEditorContextMock.ts +++ b/frontend/packages/policy-editor/test/mocks/policyEditorContextMock.ts @@ -16,6 +16,7 @@ export const mockPolicyEditorContextValue: PolicyEditorContextProps = { setPolicyRules: jest.fn(), actions: mockActions, subjects: mockSubjects, + accessPackages: [], usageType: mockUsageType, resourceType: mockResourceType, showAllErrors: false, @@ -28,6 +29,7 @@ export const mockPolicyEditorContextValueWithSingleNarrowingPolicy: PolicyEditor setPolicyRules: jest.fn(), actions: mockActions, subjects: mockSubjects, + accessPackages: [], usageType: mockUsageType, resourceType: mockResourceType, showAllErrors: false, diff --git a/frontend/packages/policy-editor/test/mocks/policyRuleMocks.ts b/frontend/packages/policy-editor/test/mocks/policyRuleMocks.ts index 8cc632c4d2d..d7acb9c447a 100644 --- a/frontend/packages/policy-editor/test/mocks/policyRuleMocks.ts +++ b/frontend/packages/policy-editor/test/mocks/policyRuleMocks.ts @@ -21,6 +21,7 @@ export const mockPolicyRuleCard1: PolicyRuleCard = { description: '', subject: [mockSubjectId1, mockSubjectId3], actions: [mockAction1.actionId, mockAction2.actionId, mockAction4.actionId], + accessPackages: [], resources: mockPolicyRuleResources, }; export const mockPolicyRuleCard2: PolicyRuleCard = { @@ -28,6 +29,7 @@ export const mockPolicyRuleCard2: PolicyRuleCard = { description: '', subject: [], actions: [], + accessPackages: [], resources: [[]], }; export const mockPolicyRuleCardWithSingleNarrowingPolicy: PolicyRuleCard = { @@ -35,6 +37,7 @@ export const mockPolicyRuleCardWithSingleNarrowingPolicy: PolicyRuleCard = { description: '', subject: [mockSubjectId1, mockSubjectId3], actions: [mockAction1.actionId, mockAction2.actionId, mockAction4.actionId], + accessPackages: [], resources: mockPolicyRuleResourcesWithSingleNarrowingPolicy, }; export const mockPolicyRuleCards: PolicyRuleCard[] = [mockPolicyRuleCard1, mockPolicyRuleCard2]; @@ -44,6 +47,7 @@ export const mockPolicyRule1: PolicyRule = { description: '', subject: [mockSubjectBackendString1, mockSubjectBackendString3], actions: [mockAction1.actionId, mockAction2.actionId, mockAction4.actionId], + accessPackages: [], resources: mockPolicyResources, }; export const mockPolicyRule2: PolicyRule = { @@ -52,5 +56,6 @@ export const mockPolicyRule2: PolicyRule = { subject: [], actions: [], resources: [[]], + accessPackages: [], }; export const mockPolicyRules: PolicyRule[] = [mockPolicyRule1, mockPolicyRule2]; diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js index 44753ad07c9..3f389f518db 100644 --- a/frontend/packages/shared/src/api/paths.js +++ b/frontend/packages/shared/src/api/paths.js @@ -149,6 +149,8 @@ export const appPolicyPath = (org, app) => `${basePath}/${org}/${app}/policy`; / export const resourcePolicyPath = (org, repo, id) => `${basePath}/${org}/${repo}/policy/${id}`; // Get, Put export const resourceActionsPath = (org, repo) => `${basePath}/${org}/${repo}/policy/actionoptions`; // Get export const resourceSubjectsPath = (org, repo) => `${basePath}/${org}/${repo}/policy/subjectoptions`; // Get +export const resourceAccessPackagesPath = (org, repo) => `${basePath}/${org}/${repo}/policy/accesspackageoptions`; // Get +export const resourceAccessPackageServicesPath = (accessPackageUrn, env) => `${basePath}/accesspackageservices/${accessPackageUrn}/${env}`; // Get export const resourcePublishStatusPath = (org, repo, id) => `${basePath}/${org}/resources/publishstatus/${repo}/${id}`; // Get export const resourceListPath = (org) => `${basePath}/${org}/resources/resourcelist?includeEnvResources=true`; // Get export const resourceCreatePath = (org) => `${basePath}/${org}/resources/addresource`; // Post diff --git a/frontend/packages/shared/src/api/queries.ts b/frontend/packages/shared/src/api/queries.ts index f0c810254ca..edf4d6f8926 100644 --- a/frontend/packages/shared/src/api/queries.ts +++ b/frontend/packages/shared/src/api/queries.ts @@ -52,9 +52,11 @@ import { repoDiffPath, getImageFileNamesPath, validateImageFromExternalUrlPath, + resourceAccessPackagesPath, authStatusAnsattporten, availableMaskinportenScopesPath, selectedMaskinportenScopesPath, + resourceAccessPackageServicesPath, optionListPath, } from './paths'; @@ -89,6 +91,7 @@ import type { ExternalImageUrlValidationResponse } from 'app-shared/types/api/Ex import type { MaskinportenScopes } from 'app-shared/types/MaskinportenScope'; import type { OptionsList, OptionsLists } from 'app-shared/types/api/OptionsLists'; import type { LayoutSetsModel } from '../types/api/dto/LayoutSetsModel'; +import type { AccessPackageResource, PolicyAccessPackageAreaGroup } from 'app-shared/types/PolicyAccessPackages'; export const getIsLoggedInWithAnsattporten = () => get<{ isLoggedIn: boolean }>(authStatusAnsattporten()); export const getMaskinportenScopes = (org: string, app: string) => get(availableMaskinportenScopesPath(org, app)); @@ -142,6 +145,8 @@ export const getAltinn2LinkServices = (org: string, environment: string) => get< export const getPolicyActions = (org: string, repo: string) => get(resourceActionsPath(org, repo)); export const getPolicy = (org: string, repo: string, id: string) => get(resourcePolicyPath(org, repo, id)); export const getPolicySubjects = (org: string, repo: string) => get(resourceSubjectsPath(org, repo)); +export const getAccessPackages = (org: string, repo: string) => get(resourceAccessPackagesPath(org, repo)); +export const getAccessPackageServices = (accessPackageUrn: string, env: string) => get(resourceAccessPackageServicesPath(accessPackageUrn, env)); export const getResource = (org: string, repo: string, id: string) => get(resourceSinglePath(org, repo, id)); export const getResourceList = (org: string) => get(resourceListPath(org)); export const getResourcePublishStatus = (org: string, repo: string, id: string) => get(resourcePublishStatusPath(org, repo, id)); diff --git a/frontend/packages/shared/src/hooks/queries/index.ts b/frontend/packages/shared/src/hooks/queries/index.ts index 0005d1e15d5..f97cf89f7a0 100644 --- a/frontend/packages/shared/src/hooks/queries/index.ts +++ b/frontend/packages/shared/src/hooks/queries/index.ts @@ -11,3 +11,4 @@ export { useTextResourcesQuery } from './useTextResourcesQuery'; export { useUserQuery } from './useUserQuery'; export { useResourcePolicyActionsQuery } from './useResourcePolicyActionsQuery'; export { useResourcePolicySubjectsQuery } from './useResourcePolicySubjectsQuery'; +export { useResourceAccessPackagesQuery } from './useResourceAccessPackagesQuery'; diff --git a/frontend/packages/shared/src/hooks/queries/useResourceAccessPackageServicesQuery.ts b/frontend/packages/shared/src/hooks/queries/useResourceAccessPackageServicesQuery.ts new file mode 100644 index 00000000000..3c148a2599f --- /dev/null +++ b/frontend/packages/shared/src/hooks/queries/useResourceAccessPackageServicesQuery.ts @@ -0,0 +1,26 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import type { AccessPackageResource } from 'app-shared/types/PolicyAccessPackages'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import type { AxiosError } from 'axios'; + +/** + * Query to get all services using a spesific access package + * + * @param accessPackageUrn urn of the access package + * @param env environment + * + * @returns UseQueryResult with a list of services + */ +export const useResourceAccessPackageServicesQuery = ( + accessPackageUrn: string, + env: string, +): UseQueryResult => { + const { getAccessPackageServices } = useServicesContext(); + + return useQuery({ + queryKey: [QueryKey.ResourcePolicyAccessPackageServices, accessPackageUrn, env], + queryFn: () => getAccessPackageServices(accessPackageUrn, env), + }); +}; diff --git a/frontend/packages/shared/src/hooks/queries/useResourceAccessPackagesQuery.ts b/frontend/packages/shared/src/hooks/queries/useResourceAccessPackagesQuery.ts new file mode 100644 index 00000000000..6c709c10d18 --- /dev/null +++ b/frontend/packages/shared/src/hooks/queries/useResourceAccessPackagesQuery.ts @@ -0,0 +1,25 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import type { PolicyAccessPackageAreaGroup } from 'app-shared/types/PolicyAccessPackages'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import type { AxiosError } from 'axios'; + +/** + * Query to get the list of access package categories + * + * @param org the organisation of the user + * + * @returns UseQueryResult with a list of access package categories + */ +export const useResourceAccessPackagesQuery = ( + org: string, + repo: string, +): UseQueryResult => { + const { getAccessPackages } = useServicesContext(); + + return useQuery({ + queryKey: [QueryKey.ResourcePolicyAccessPackages, org], + queryFn: () => getAccessPackages(org, repo), + }); +}; diff --git a/frontend/packages/shared/src/mocks/queriesMock.ts b/frontend/packages/shared/src/mocks/queriesMock.ts index 518ba20b485..90b47697222 100644 --- a/frontend/packages/shared/src/mocks/queriesMock.ts +++ b/frontend/packages/shared/src/mocks/queriesMock.ts @@ -149,6 +149,8 @@ export const queriesMock: ServicesContextProps = { getPolicyActions: jest.fn().mockImplementation(() => Promise.resolve([])), getPolicy: jest.fn().mockImplementation(() => Promise.resolve(policy)), getPolicySubjects: jest.fn().mockImplementation(() => Promise.resolve([])), + getAccessPackages: jest.fn().mockImplementation(() => Promise.resolve([])), + getAccessPackageServices: jest.fn().mockImplementation(() => Promise.resolve([])), getResource: jest.fn().mockImplementation(() => Promise.resolve(resource)), getResourceList: jest.fn().mockImplementation(() => Promise.resolve([])), getResourcePublishStatus: jest diff --git a/frontend/packages/shared/src/types/PolicyAccessPackages.ts b/frontend/packages/shared/src/types/PolicyAccessPackages.ts new file mode 100644 index 00000000000..7ec1b76eb62 --- /dev/null +++ b/frontend/packages/shared/src/types/PolicyAccessPackages.ts @@ -0,0 +1,40 @@ +export type PolicyAccessPackage = { + id: string; + urn: string; + name: string; + description: string; +}; + +export type PolicyAccessPackageArea = { + id: string; + urn: string; + name: string; + description: string; + icon: string; + areaGroup: string; + packages: PolicyAccessPackage[]; +}; + +export type PolicyAccessPackageAreaGroup = { + id: string; + urn: string; + name: string; + description: string; + type: string; + areas: PolicyAccessPackageArea[]; +}; + +type AccessPackageResourceLanguage = 'nb' | 'nn' | 'en'; + +type CompetentAuthority = { + name: Record; + organization: string; + orgcode: string; +}; + +export type AccessPackageResource = { + identifier: string; + title: Record; + hasCompetentAuthority?: CompetentAuthority; + logoUrl: string; +}; diff --git a/frontend/packages/shared/src/types/QueryKey.ts b/frontend/packages/shared/src/types/QueryKey.ts index 5db7f25c245..bea955f6158 100644 --- a/frontend/packages/shared/src/types/QueryKey.ts +++ b/frontend/packages/shared/src/types/QueryKey.ts @@ -55,6 +55,8 @@ export enum QueryKey { ResourcePolicy = 'ResourcePolicy', ResourcePolicyActions = 'ResourcePolicyActions', ResourcePolicySubjects = 'ResourcePolicySubjects', + ResourcePolicyAccessPackages = 'ResourcePolicyAccessPackages', + ResourcePolicyAccessPackageServices = 'ResourcePolicyAccessPackageServices', ResourcePublishStatus = 'ResourcePublishStatus', ResourceSectors = 'ResourceSectors', ResourceThematicEurovoc = 'ResourceThematicEurovoc', diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.ts b/frontend/packages/shared/src/utils/featureToggleUtils.ts index f2af05d02bc..df5dfc0aa20 100644 --- a/frontend/packages/shared/src/utils/featureToggleUtils.ts +++ b/frontend/packages/shared/src/utils/featureToggleUtils.ts @@ -10,6 +10,7 @@ export enum FeatureFlag { Maskinporten = 'maskinporten', MultipleDataModelsPerTask = 'multipleDataModelsPerTask', OptionListEditor = 'optionListEditor', + AccessPackages = 'accessPackages', ShouldOverrideAppLibCheck = 'shouldOverrideAppLibCheck', Subform = 'subform', Summary2 = 'summary2', diff --git a/frontend/resourceadm/pages/PolicyEditorPage/PolicyEditorPage.tsx b/frontend/resourceadm/pages/PolicyEditorPage/PolicyEditorPage.tsx index d187a5b86ff..754715cdd3b 100644 --- a/frontend/resourceadm/pages/PolicyEditorPage/PolicyEditorPage.tsx +++ b/frontend/resourceadm/pages/PolicyEditorPage/PolicyEditorPage.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { useResourcePolicyActionsQuery, useResourcePolicySubjectsQuery, + useResourceAccessPackagesQuery, } from 'app-shared/hooks/queries'; import { useUrlParams } from '../../hooks/useUrlParams'; @@ -49,6 +50,8 @@ export const PolicyEditorPage = ({ org, app, ); + const { data: accessPackages, isPending: isLoadingAccessPackages } = + useResourceAccessPackagesQuery(org, app); // Mutation function to update policy const { mutate: updatePolicyMutation } = useEditResourcePolicyMutation(org, app, resourceId); @@ -68,7 +71,7 @@ export const PolicyEditorPage = ({ * Displays the content based on the state of the page */ const displayContent = () => { - if (isPolicyPending || isActionPending || isSubjectsPending) { + if (isPolicyPending || isActionPending || isSubjectsPending || isLoadingAccessPackages) { return (