diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0cf3f7bf..44fd99cf8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ _**For better traceability add the corresponding GitHub issue number in each cha - #965 Implement proxy functionality of the IRS policy store ### Added +- #832 added policymanagement list view, creator and editor - #737 Added concept: Contract table -> parts link action - XXX Added interceptor to EdcRestTemplates to log requests diff --git a/docs/src/docs/user/user-manual.adoc b/docs/src/docs/user/user-manual.adoc index 19e2effc35..72c181fe04 100644 --- a/docs/src/docs/user/user-manual.adoc +++ b/docs/src/docs/user/user-manual.adoc @@ -59,7 +59,7 @@ To login use the credentials provided by the hosting company. Navigation is done based on the top menu. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/navigation/navigation-overview.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/navigation/navigation-overview.png[] === Dashboard @@ -92,17 +92,17 @@ Navigates to the Catena-X portal. Only applicable for the admin user role. Possibility to check the network status based on logfiles and will provide access to configuration possibilities for the application. -==== Contracts view and export +==== Contracts - view and export In the Contracts view an admin user can view contract agreements and sort them by the contract ID. Also, it's possible to select contracts and export/download them as a .csv file. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/navigation/admin_contract_view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/navigation/admin_contract_view.png[] By clicking on the burger menu of a data row you can get to the detailed view of a contract. -==== Contract detailed view +===== Contract detailed view The contract detailed view is divided into two sections. @@ -114,9 +114,9 @@ displayed in JSON format. Use the view selector to switch between JSON view and JSON tree view. Expand the policy details card on the right upper side for full-width display. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/navigation/admin_contract_detailed_view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/navigation/admin_contract_detailed_view.png[] -==== Data import functionality +==== Data provisioning With the admin user role, you have the ability to import data into the system. @@ -127,17 +127,79 @@ As you can see in the picture below, you can select a file to import and click o Find the example file at the following link: https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/tx-backend/testdata/import-test-data-CML1_v0.0.12.json -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/navigation/admin_upload_file.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/navigation/admin_upload_file.png[] The system will validate the file content. Upon successful validation, assets will be saved as either "AssetAsPlanned" or "AssetAsBuilt", with the import state set to "transient." +=== Policy Management + +The policy management feature allows administrators to create, edit, view, and delete policies within the system. +This section provides an overview of how to use these features effectively. + +==== Policies List View + +The policies list view displays all the policies in a tabular format. +You can perform various actions such as view, edit, and delete policies from this view. + +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/policy-management/policies-list-view.png[] + +To access the policies list view, navigate to the "Policies" section from the top menu. + +By clicking on the settings symbol in the top right corner of the table, you are able to customize the visibility and order of the table columns. + +In the top left corner you can initiate the creation or deletion of policies. + +===== Deleting Policies + +To delete policies, follow these steps: + +Select the policies you wish to delete by checking the boxes next to them. +Click on the delete icon to open the deletion dialog. +Confirm the deletion in the dialog. +The system will then remove the selected policies and update the list view. +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/policy-management/delete-policies-dialog.png[] + +==== Policy Editor / Detailed View + +The policy editor allows you to create, edit, and view detailed information about a policy. + +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/policy-management/policy-editor.png[] + +Note: For existing policies, it is currently only possible to edit the valid until date and the BPN number. + +===== Creating a policy + +To create a policy: + +Navigate to the "Create Policy" section from the policies list view by clicking the plus icon in the top left corner of the table. +Fill in the policy details including policy name, validity date, BPN number(s), access type, and constraints. +Save the policy using the save button. +The system will validate the inputs and update the policy accordingly. +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/policy-management/policy-create.png[] + +==== Constraints + +Constraints define the conditions under which the policy is applicable. +You can add, edit, and remove constraints in the policy editor. + +To add a constraint: + +Click the add button in the constraints section. +Fill in the left operand, operator, and right operand. +Save the constraint. +To remove a constraint, click the delete button next to the constraint. + +To move constraints up or down in the list, use the up and down arrow buttons. + +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/policy-management/policy-constraints.png[] + === Sign out Sign out the current user and return to the Catena-X portal. === Language -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/navigation/language-icon.png[] Change language. + +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/navigation/language-icon.png[] Change language. + Supported languages: * English @@ -148,7 +210,7 @@ Supported languages: List view of the own manufactured (asBuilt) or planned (asPlanned) parts and batches as well as supplier/customer parts. You can adjust the view of tables by clicking on the fullscreen icon to maximize or minimize the view to the half of the full width. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/parts-list-view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/parts-list-view.png[] === Parts table @@ -164,7 +226,7 @@ The global search bar at the top returns part results from both tables. Choosing the filter input field for any column and typing in any character will show filter suggestions. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/parts-autosuggestion-filtering.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/parts-autosuggestion-filtering.png[] === AsBuilt lifecycle parts @@ -195,12 +257,12 @@ Parts which exist in a quality notification will be highlighted as a yellow colo Select one or multiple parts that are in the AsBuilt lifecycle. A button will appear on the right of the lifecycle view selection: -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/publish_assets_button.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/publish_assets_button.png[] Selection will enable you to publish assets with the goal to persist them (import state "persistent"). With a click on the button a window will be opened, where the selected assets are displayed and a required policy must be selected: -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/publish_assets_view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/publish_assets_view.png[] The following table explains the different import state an asset can have: @@ -230,7 +292,7 @@ The following table explains the different import state an asset can have: On the right upper site of a table there is a settings icon in which you can set the table columns to a desired view. With a click on it a dialog opens where you can change the settings of the corresponding table: -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/other-parts-table-settings-dialog.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/other-parts-table-settings-dialog.png[] Hide/show table columns by clicking on the checkbox or the column name. It is possible to hide/show all columns by clicking on the "All" - checkbox. @@ -250,7 +312,7 @@ The settings will be stored in the local storage of the browser and will be pers To open the detail view, click on the three dots icon of the desired item and select "View details". More detailed information on the asset is listed as well as a part tree that visually shows the parts relations. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/parts-list-detailed-view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/parts-list-detailed-view.png[] ==== Overview @@ -267,7 +329,7 @@ A yellow border indicates that the part is a piece of a batch. It is possible to adjust the view of the relationships by dragging the mouse to the desired view. Zooming in/out can be done with the corresponding control buttons. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/open-new-tab.png[] Open part tree in new tab to zoom, scroll and focus in a larger view. +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/open-new-tab.png[] Open part tree in new tab to zoom, scroll and focus in a larger view. A minimap on the bottom right provides an overview of the current position on the part tree. ==== Asset state @@ -303,7 +365,7 @@ a tooltip will provide information explaining the reason. You can trigger the to To open the detail view, click on the three dots icon of the desired item from the parts table and select "View details". More detailed information on the asset is listed. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/supplier-parts-list-detailed-view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/supplier-parts-list-detailed-view.png[] ==== Overview @@ -328,7 +390,7 @@ Customer Parts that are in a quality alert are highlighted yellow. To open the detail view, click on the three dots icon of the desired item and select "View details". More detailed information on the asset is listed. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/parts/customer-parts-list-detailed-view.png[] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/parts/customer-parts-list-detailed-view.png[] ==== Overview @@ -347,20 +409,20 @@ Information about the identifiers at the customer for the respective part/batch. Inbox for received/sent quality notifications. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/investigations-list-view.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/investigations-list-view.png[] The tables can be sorted, filtered and searched. Choosing the filter input field for any column and typing in any character will show filter suggestions. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/investigations-autosuggestion-filtering.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/investigations-autosuggestion-filtering.png[] -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/notification-drafts.png[] Received quality notifications. +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/notification-drafts.png[] Received quality notifications. Quality notifications received by a customer. Those notifications specify a defect or request to investigate on a specific part / batch on your side and give feedback to the customer. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/notification-send.png[] Sent quality notifications. +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/notification-send.png[] Sent quality notifications. Notifications in the context of quality investigations that are in queued/draft status or already requested/sent to the supplier. Those notifications specify a defect or request to investigate on a specific part / batch on your suppliers side and give feedback back to you. @@ -389,7 +451,7 @@ Notifications can be selected with the checkboxes on the left of the table. With the selection, there is a context menu for actions on mulitple (selected) notifications. The "more" menu is opened by clicking on the horizontally aligned three dots icon. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/inbox-multiselect-actions.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/inbox-multiselect-actions.png[] === Quality notification create/edit view @@ -400,7 +462,7 @@ A quality notification can be started by the following options: A quality notification can be edited by clicking on the context menu on an item within the inbox. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/investigation-create-view.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/investigation-create-view.png[] === Quality notifications context action @@ -409,11 +471,11 @@ Select the three dots icon on the right side of an quality notification entry to From there it is possible to open the quality notification detailed view or change the status of it. Only the possible status transition will show up. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/notification-context-action.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/notification-context-action.png[] Changing the status of a quality notification will open a modal in which the details to the status change can be provided and completed. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/investigation-context-action-modal.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/investigation-context-action-modal.png[] A pop-up will notify you if the status transition was successful. @@ -421,7 +483,7 @@ A pop-up will notify you if the status transition was successful. The quality notification detail view can be opened by selecting the corresponding option in the context menu. -image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/investigation-detail-view.png[] +image:https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/investigation-detail-view.png[] ==== Overview @@ -491,4 +553,4 @@ The receiver can never change the status to closed. The legend in the below state diagram describes who can set the status. One exception to this rule: the transition from status SENT to status RECEIVED is done automatically once the sender receives the Http status code 201. -image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/arc42/user-manual/quality-notifications/notificationstatemodel.png[Notification state model] +image::https://raw.githubusercontent.com/eclipse-tractusx/traceability-foss/main/docs/src/images/user-manual/quality-notifications/notificationstatemodel.png[Notification state model] diff --git a/docs/src/images/user-manual/policy-management/delete-policies-dialog.png b/docs/src/images/user-manual/policy-management/delete-policies-dialog.png new file mode 100644 index 0000000000..3f42946e0b Binary files /dev/null and b/docs/src/images/user-manual/policy-management/delete-policies-dialog.png differ diff --git a/docs/src/images/user-manual/policy-management/policies-list-view.png b/docs/src/images/user-manual/policy-management/policies-list-view.png new file mode 100644 index 0000000000..52058c5095 Binary files /dev/null and b/docs/src/images/user-manual/policy-management/policies-list-view.png differ diff --git a/docs/src/images/user-manual/policy-management/policy-constraints.png b/docs/src/images/user-manual/policy-management/policy-constraints.png new file mode 100644 index 0000000000..4dfbd00720 Binary files /dev/null and b/docs/src/images/user-manual/policy-management/policy-constraints.png differ diff --git a/docs/src/images/user-manual/policy-management/policy-create.png b/docs/src/images/user-manual/policy-management/policy-create.png new file mode 100644 index 0000000000..0aae8e66f4 Binary files /dev/null and b/docs/src/images/user-manual/policy-management/policy-create.png differ diff --git a/docs/src/images/user-manual/policy-management/policy-editor.png b/docs/src/images/user-manual/policy-management/policy-editor.png new file mode 100644 index 0000000000..23b9443770 Binary files /dev/null and b/docs/src/images/user-manual/policy-management/policy-editor.png differ diff --git a/frontend/src/app/mocks/services/policy-mock/policy.handler.ts b/frontend/src/app/mocks/services/policy-mock/policy.handler.ts index a4a019abe6..504045f0ea 100644 --- a/frontend/src/app/mocks/services/policy-mock/policy.handler.ts +++ b/frontend/src/app/mocks/services/policy-mock/policy.handler.ts @@ -18,12 +18,31 @@ ********************************************************************************/ import { environment } from '@env'; import { rest } from 'msw'; -import { getPolicies } from './policy.model'; +import { getPolicies, getPolicyById } from './policy.model'; export const policyHandler = (_ => { return [ rest.get(`*${ environment.apiUrl }/policies`, (req, res, ctx) => { return res(ctx.status(200), ctx.json(getPolicies())); - }) + }), + + rest.post(`*${ environment.apiUrl }/policies`, (req, res, ctx) => { + return res(ctx.status(201), ctx.json('success')); + }), + + rest.get(`*${ environment.apiUrl }/policies/:policyId`, (req, res, ctx) => { + const { policyId } = req.params; + const policy = getPolicyById(policyId); + return res(ctx.status(200), ctx.json(policy)); + }), + + rest.put(`*${ environment.apiUrl }/policies`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json('success')); + }), + + rest.delete(`*${ environment.apiUrl }/policies/:policyId`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json('success')); + }), + ] })(); diff --git a/frontend/src/app/mocks/services/policy-mock/policy.model.spec.ts b/frontend/src/app/mocks/services/policy-mock/policy.model.spec.ts new file mode 100644 index 0000000000..6a283ea82f --- /dev/null +++ b/frontend/src/app/mocks/services/policy-mock/policy.model.spec.ts @@ -0,0 +1,31 @@ +import { getOperatorTypeSign, OperatorType } from '@page/policies/model/policy.model'; + +describe('getOperatorTypeSign', () => { + it('should return "=" for OperatorType.EQ', () => { + expect(getOperatorTypeSign(OperatorType.EQ)).toBe('='); + }); + + it('should return "!=" for OperatorType.NEQ', () => { + expect(getOperatorTypeSign(OperatorType.NEQ)).toBe('!='); + }); + + it('should return "<" for OperatorType.LT', () => { + expect(getOperatorTypeSign(OperatorType.LT)).toBe('<'); + }); + + it('should return ">" for OperatorType.GT', () => { + expect(getOperatorTypeSign(OperatorType.GT)).toBe('>'); + }); + + it('should return "<=" for OperatorType.LTEQ', () => { + expect(getOperatorTypeSign(OperatorType.LTEQ)).toBe('<='); + }); + + it('should return ">=" for OperatorType.GTEQ', () => { + expect(getOperatorTypeSign(OperatorType.GTEQ)).toBe('>='); + }); + + it('should return the string representation of the type for unknown types', () => { + expect(getOperatorTypeSign('UNKNOWN' as OperatorType)).toBe('UNKNOWN'); + }); +}); diff --git a/frontend/src/app/mocks/services/policy-mock/policy.model.ts b/frontend/src/app/mocks/services/policy-mock/policy.model.ts index e80e7d4637..84e0a8f605 100644 --- a/frontend/src/app/mocks/services/policy-mock/policy.model.ts +++ b/frontend/src/app/mocks/services/policy-mock/policy.model.ts @@ -1,3 +1,5 @@ +import { OperatorType, Policy, PolicyAction, PolicyResponseMap } from '@page/policies/model/policy.model'; + /******************************************************************************** * Copyright (c) 2022, 2023, 2024 Contributors to the Eclipse Foundation * @@ -17,36 +19,183 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ // For now Mocks are built by current response (any). This is because Policy Model changes frequently -export const getPolicies = () => [ +export const getPolicies = (): Policy[] => { + return mockedPolicyList; +}; + +export const getPolicyById = (policyId: string | ReadonlyArray): Policy => { + return mockedPolicyList.filter(policy => policy.policyId === policyId)[0]; +}; + + +export const mockedPolicyList: Policy[] = [ { - policyId: "Mocked_Policy_Id", - createdOn: 1706690077.834424001, - validUntil: 1727740799.990000000, - permissions: [ + 'policyId': 'default-policy', + 'bpn': 'BPNL00000003CML1,BPNL00000003CNKC', + 'createdOn': '2024-05-29T06:18:40Z', + 'validUntil': '2029-05-29T06:18:40Z', + 'permissions': [ { - action: "USE", - constraints: [ - { + 'action': PolicyAction.USE, + 'constraints': { + 'and': null, + 'or': [ + { + 'leftOperand': 'cx-policy:FrameworkAgreement', + 'operatorTypeResponse': OperatorType.EQ, + 'rightOperand': 'traceability:1.0', + }, + { + 'leftOperand': 'cx-policy:UsagePurpose', + 'operatorTypeResponse': OperatorType.EQ, + 'rightOperand': 'cx.core.industrycore:1', + }, + ], + }, + }, + ], + }, + { + 'policyId': 'default-policy-2', + 'bpn': 'BPNL00000003CML1', + 'createdOn': '2024-05-29T06:18:40Z', + 'validUntil': '2029-05-29T06:18:40Z', + 'permissions': [ + { + 'action': PolicyAction.USE, + 'constraints': { + 'and': [ + { + 'leftOperand': 'cx-policy:FrameworkAgreement', + 'operatorTypeResponse': OperatorType.EQ, + 'rightOperand': 'traceability:1.0', + }, + { + 'leftOperand': 'cx-policy:UsagePurpose', + 'operatorTypeResponse': OperatorType.EQ, + 'rightOperand': 'cx.core.industrycore:1', + }, + ], + 'or': null, + }, + }, + ], + }, +]; + +const mockedPolicies = { + page: 0, + pageCount: 0, + pageSize: 10, + totalItems: 2, + + content: [ + { + bpnSelection: [ 'BPN10000000OEM0A', 'BPN10000000OEM0B' ], + policyName: 'Mocked_Policy_Name_1', + policyId: 'Mocked_Policy_1', + accessType: PolicyAction.ACCESS, + createdOn: '2024-05-29T06:18:40Z', + validUntil: '2024-05-29T06:18:40Z', + constraints: [ 'Membership = active', 'AND FrameworkAgreement.traceability in [active]', 'AND PURPOSE = ID 3.1 Trace' ], + permissions: [ + { + action: PolicyAction.USE, + constraint: { + and: [ + { + leftOperand: 'PURPOSE', + operator: { + id: OperatorType.EQ, + }, + rightOperand: 'ID 3.0 Trace', + }, + ], + or: [ + { + leftOperand: 'PURPOSE', + operator: { + id: OperatorType.EQ, + }, + rightOperand: 'ID 3.0 Trace', + }, + ], + }, + }, + ], + }, + { + bpnSelection: [ 'BPN10000000OEM0A', 'BPN10000000OEM0B' ], + policyName: 'Mocked_Policy_Name_2', + policyId: 'Mocked_Policy_2', + accessType: PolicyAction.USE, + createdOn: '2024-05-29T06:18:40Z', + validUntil: '2024-05-29T06:18:40Z', + constraints: [ 'PURPOSE = ID 3.1 Trace', 'OR PURPOSE = ID 3.0 Trace' ], + permissions: [ + { + action: PolicyAction.USE, + constraint: { and: [ { - leftOperand: "PURPOSE", - operatorTypeResponse: "EQ", - rightOperands: [ - "ID 3.0 Trace" - ] + leftOperand: 'PURPOSE', + operator: { + id: OperatorType.IN, + }, + rightOperand: 'BMW', }, + ], + or: [ { - leftOperand: "PURPOSE", - operatorTypeResponse: "EQ", - rightOperands: [ - "ID 3.0 Trace" - ] - } + leftOperand: 'PURPOSE', + operator: { + id: OperatorType.EQ, + }, + rightOperand: 'ID 3.0 Trace', + }, ], - or: [] - } - ] - } - ] - } -] + }, + }, + ], + }, + + ], + +}; + +export const MockPolicyResponseMap: PolicyResponseMap = { + 'default': [ + { + 'validUntil': '2024-06-30T11:07:00Z', + 'payload': { + '@context': { + 'odrl': 'http://www.w3.org/ns/odrl/2/', + }, + '@id': 'asdadasdas', + 'policy': { + 'policyId': 'asdadasdas', + 'createdOn': '2024-06-13T09:07:32.229901783Z', + 'validUntil': '2024-06-30T11:07:00Z', + 'permissions': [ + { + 'action': PolicyAction.USE, + 'constraint': { + 'and': null, + 'or': [ + { + 'leftOperand': 'asd', + 'operator': { + '@id': OperatorType.EQ, + }, + 'odrl:rightOperand': 'dsa', + }, + ], + }, + }, + ], + }, + }, + }, + ], +}; + diff --git a/frontend/src/app/modules/core/user/table-settings.service.ts b/frontend/src/app/modules/core/user/table-settings.service.ts index ae3d6b893e..5d4a15bbe4 100644 --- a/frontend/src/app/modules/core/user/table-settings.service.ts +++ b/frontend/src/app/modules/core/user/table-settings.service.ts @@ -23,6 +23,7 @@ import { NotificationsReceivedConfigurationModel } from '@shared/components/part import { NotificationsSentConfigurationModel } from '@shared/components/parts-table/notifications-sent-configuration.model'; import { PartsAsBuiltConfigurationModel } from '@shared/components/parts-table/parts-as-built-configuration.model'; import { PartsAsPlannedConfigurationModel } from '@shared/components/parts-table/parts-as-planned-configuration.model'; +import { PoliciesConfigurationModel } from '@shared/components/parts-table/policies-configuration.model'; import { TableViewConfig } from '@shared/components/parts-table/table-view-config.model'; import { ToastService } from '@shared/components/toasts/toast.service'; import { Subject } from 'rxjs'; @@ -112,6 +113,8 @@ export class TableSettingsService { return new NotificationsSentConfigurationModel().filterConfiguration(); case TableType.RECEIVED_NOTIFICATION: return new NotificationsReceivedConfigurationModel().filterConfiguration(); + case TableType.POLICIES: + return new PoliciesConfigurationModel().filterConfiguration(); } } diff --git a/frontend/src/app/modules/page/admin/admin.module.ts b/frontend/src/app/modules/page/admin/admin.module.ts index d0331225a8..6549348455 100644 --- a/frontend/src/app/modules/page/admin/admin.module.ts +++ b/frontend/src/app/modules/page/admin/admin.module.ts @@ -19,29 +19,36 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -import {CommonModule} from '@angular/common'; -import {NgModule} from '@angular/core'; -import {getI18nPageProvider} from '@core/i18n'; -import {AdminFacade} from '@page/admin/core/admin.facade'; -import {AdminService} from '@page/admin/core/admin.service'; -import {ContractDetailComponent} from '@page/admin/presentation/contracts/contract-detail/contract-detail.component'; -import {ContractsComponent} from '@page/admin/presentation/contracts/contracts.component'; -import {ContractsFacade} from '@page/admin/presentation/contracts/contracts.facade'; -import {ContractsState} from '@page/admin/presentation/contracts/contracts.state'; -import {ModalModule} from '@shared/modules/modal/modal.module'; -import {SharedModule} from '@shared/shared.module'; -import {TemplateModule} from '@shared/template.module'; -import {AdminRoutingModule} from './admin.routing'; -import {AdminComponent} from './presentation/admin.component'; -import {BpnConfigurationComponent} from './presentation/bpn-configuration/bpn-configuration.component'; -import {SaveBpnConfigModal} from './presentation/bpn-configuration/save-modal/save-modal.component'; -import {ImportJsonComponent} from './presentation/import-json/import-json.component'; -import {NgxJsonViewerModule} from "ngx-json-viewer"; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatLineModule } from '@angular/material/core'; +import { getI18nPageProvider } from '@core/i18n'; +import { AdminFacade } from '@page/admin/core/admin.facade'; +import { AdminService } from '@page/admin/core/admin.service'; +import { ContractDetailComponent } from '@page/admin/presentation/contracts/contract-detail/contract-detail.component'; +import { ContractsComponent } from '@page/admin/presentation/contracts/contracts.component'; +import { ContractsFacade } from '@page/admin/presentation/contracts/contracts.facade'; +import { ContractsState } from '@page/admin/presentation/contracts/contracts.state'; +import { DeletionDialogComponent } from '@page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component'; +import { PoliciesComponent } from '@page/admin/presentation/policy-management/policies/policies.component'; +import { PoliciesFacade } from '@page/admin/presentation/policy-management/policies/policies.facade'; +import { PoliciesState } from '@page/admin/presentation/policy-management/policies/policies.state'; +import { PolicyEditorComponent } from '@page/admin/presentation/policy-management/policy-editor/policy-editor.component'; +import { ModalModule } from '@shared/modules/modal/modal.module'; +import { PolicyService } from '@shared/service/policy.service'; +import { SharedModule } from '@shared/shared.module'; +import { TemplateModule } from '@shared/template.module'; +import { NgxJsonViewerModule } from 'ngx-json-viewer'; +import { AdminRoutingModule } from './admin.routing'; +import { AdminComponent } from './presentation/admin.component'; +import { BpnConfigurationComponent } from './presentation/bpn-configuration/bpn-configuration.component'; +import { SaveBpnConfigModal } from './presentation/bpn-configuration/save-modal/save-modal.component'; +import { ImportJsonComponent } from './presentation/import-json/import-json.component'; @NgModule({ - declarations: [ AdminComponent, BpnConfigurationComponent, SaveBpnConfigModal, ImportJsonComponent, ContractsComponent, ContractDetailComponent ], - imports: [CommonModule, TemplateModule, SharedModule, AdminRoutingModule, ModalModule, NgxJsonViewerModule], - providers: [ ...getI18nPageProvider('page.admin'), AdminService, AdminFacade, ContractsFacade, ContractsState ], + declarations: [ AdminComponent, BpnConfigurationComponent, SaveBpnConfigModal, ImportJsonComponent, ContractsComponent, ContractDetailComponent, PoliciesComponent, DeletionDialogComponent, PolicyEditorComponent ], + imports: [ CommonModule, TemplateModule, SharedModule, AdminRoutingModule, ModalModule, NgxJsonViewerModule, MatLineModule ], + providers: [ ...getI18nPageProvider('page.admin'), AdminService, AdminFacade, ContractsFacade, ContractsState, PoliciesFacade, PoliciesState, PolicyService ], }) export class AdminModule { } diff --git a/frontend/src/app/modules/page/admin/admin.routing.ts b/frontend/src/app/modules/page/admin/admin.routing.ts index bfa17d45d8..298a468063 100644 --- a/frontend/src/app/modules/page/admin/admin.routing.ts +++ b/frontend/src/app/modules/page/admin/admin.routing.ts @@ -27,6 +27,8 @@ import { BpnConfigurationComponent } from '@page/admin/presentation/bpn-configur import { ContractDetailComponent } from '@page/admin/presentation/contracts/contract-detail/contract-detail.component'; import { ContractsComponent } from '@page/admin/presentation/contracts/contracts.component'; import { ImportJsonComponent } from '@page/admin/presentation/import-json/import-json.component'; +import { PoliciesComponent } from '@page/admin/presentation/policy-management/policies/policies.component'; +import { PolicyEditorComponent } from '@page/admin/presentation/policy-management/policy-editor/policy-editor.component'; import { I18NEXT_NAMESPACE_RESOLVER } from 'angular-i18next'; export /** @type {*} */ @@ -67,6 +69,38 @@ const ADMIN_ROUTING: Routes = [ resolve: { i18next: I18NEXT_NAMESPACE_RESOLVER }, canActivate: [ RoleGuard ], }, + { + path: KnownAdminRoutes.POLICY_MANAGEMENT, + pathMatch: 'full', + component: PoliciesComponent, + data: { i18nextNamespaces: [ 'page.admin' ] }, + resolve: { i18next: I18NEXT_NAMESPACE_RESOLVER }, + canActivate: [ RoleGuard ], + }, + { + path: KnownAdminRoutes.POLICY_MANAGEMENT_EDIT, + pathMatch: 'full', + component: PolicyEditorComponent, + data: { i18nextNamespaces: [ 'page.admin' ] }, + resolve: { i18next: I18NEXT_NAMESPACE_RESOLVER }, + canActivate: [ RoleGuard ], + }, + { + path: KnownAdminRoutes.POLICY_MANAGEMENT_CREATE, + pathMatch: 'full', + component: PolicyEditorComponent, + data: { i18nextNamespaces: [ 'page.admin' ] }, + resolve: { i18next: I18NEXT_NAMESPACE_RESOLVER }, + canActivate: [ RoleGuard ], + }, + { + path: KnownAdminRoutes.POLICY_MANAGEMENT_DETAIL_VIEW, + pathMatch: 'full', + component: PolicyEditorComponent, + data: { i18nextNamespaces: [ 'page.admin' ] }, + resolve: { i18next: I18NEXT_NAMESPACE_RESOLVER }, + canActivate: [ RoleGuard ], + }, ]; @NgModule({ diff --git a/frontend/src/app/modules/page/admin/core/admin.model.ts b/frontend/src/app/modules/page/admin/core/admin.model.ts index 3f2e83e1f1..0205a23ba5 100644 --- a/frontend/src/app/modules/page/admin/core/admin.model.ts +++ b/frontend/src/app/modules/page/admin/core/admin.model.ts @@ -20,15 +20,19 @@ ********************************************************************************/ -import {FormArray, FormControl, FormGroup} from '@angular/forms'; -import {CalendarDateModel} from '@core/model/calendar-date.model'; -import {Pagination, PaginationResponse} from '@core/model/pagination.model'; +import { FormArray, FormControl, FormGroup } from '@angular/forms'; +import { CalendarDateModel } from '@core/model/calendar-date.model'; +import { Pagination, PaginationResponse } from '@core/model/pagination.model'; export enum KnownAdminRoutes { BPN = 'configure-bpn', IMPORT = 'configure-import', CONTRACT = 'contracts', - CONTRACT_DETAIL_VIEW = 'contracts/:contractId' + CONTRACT_DETAIL_VIEW = 'contracts/:contractId', + POLICY_MANAGEMENT = 'policies', + POLICY_MANAGEMENT_EDIT = 'policies/edit/:policyId', + POLICY_MANAGEMENT_CREATE = 'policies/create', + POLICY_MANAGEMENT_DETAIL_VIEW = 'policies/:policyId', } diff --git a/frontend/src/app/modules/page/admin/presentation/admin.component.scss b/frontend/src/app/modules/page/admin/presentation/admin.component.scss index ad692e9da4..03169a2691 100644 --- a/frontend/src/app/modules/page/admin/presentation/admin.component.scss +++ b/frontend/src/app/modules/page/admin/presentation/admin.component.scss @@ -38,6 +38,7 @@ border: none; overflow: visible; position: relative; + height: 80vh; } .sidenav--item { @@ -82,7 +83,7 @@ .sidenav--toggle-button { position: absolute; - bottom: 30px; + bottom: 40vh; right: -20px; z-index: 1; } diff --git a/frontend/src/app/modules/page/admin/presentation/admin.component.ts b/frontend/src/app/modules/page/admin/presentation/admin.component.ts index cb4a9390eb..d1b049c9f6 100644 --- a/frontend/src/app/modules/page/admin/presentation/admin.component.ts +++ b/frontend/src/app/modules/page/admin/presentation/admin.component.ts @@ -52,6 +52,11 @@ export class AdminComponent { icon: 'assignment_ind', link: '/admin/contracts', }, + { + name: 'routing.adminPolicies', + icon: 'description', + link: '/admin/policies', + }, ]; constructor(router: Router) { diff --git a/frontend/src/app/modules/page/admin/presentation/bpn-configuration/bpn-configuration.component.ts b/frontend/src/app/modules/page/admin/presentation/bpn-configuration/bpn-configuration.component.ts index 689c4959e7..4868e3de8c 100644 --- a/frontend/src/app/modules/page/admin/presentation/bpn-configuration/bpn-configuration.component.ts +++ b/frontend/src/app/modules/page/admin/presentation/bpn-configuration/bpn-configuration.component.ts @@ -21,15 +21,16 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { BpnConfig, BpnConfigFormGroup } from '@page/admin/core/admin.model'; import { AdminFacade } from '@page/admin/core/admin.facade'; -import { BehaviorSubject, combineLatest, Observable, of, Subject, Subscription, tap } from 'rxjs'; -import { BaseInputHelper } from '@shared/abstraction/baseInput/baseInput.helper'; -import { BpnConfigEntry, ChangedInformation } from './bpn-configuration.model'; +import { BpnConfig, BpnConfigFormGroup } from '@page/admin/core/admin.model'; import { SaveBpnConfigModal } from '@page/admin/presentation/bpn-configuration/save-modal/save-modal.component'; +import { BaseInputHelper } from '@shared/abstraction/baseInput/baseInput.helper'; +import { BehaviorSubject, combineLatest, Observable, of, Subject, Subscription, tap } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { BpnConfigEntry, ChangedInformation } from './bpn-configuration.model'; export const bpnRegex = /^BPN[ALS][0-9A-Za-z]{10}[0-9A-Za-z]{2}$/; +export const bpnListRegex = /^(BPN[ALS][0-9A-Za-z]{10}[0-9A-Za-z]{2})(,\s*BPN[ALS][0-9A-Za-z]{10}[0-9A-Za-z]{2})*$/; @Component({ selector: 'app-bpn-configuration', diff --git a/frontend/src/app/modules/page/admin/presentation/contracts/contracts.component.spec.ts b/frontend/src/app/modules/page/admin/presentation/contracts/contracts.component.spec.ts index cf4d151bdd..141ea7e247 100644 --- a/frontend/src/app/modules/page/admin/presentation/contracts/contracts.component.spec.ts +++ b/frontend/src/app/modules/page/admin/presentation/contracts/contracts.component.spec.ts @@ -12,113 +12,117 @@ import { ContractsComponent } from './contracts.component'; describe('ContractTableComponent', () => { - const mockAdminFacade = { - getContracts: jasmine.createSpy().and.returnValue(of(getContracts)) - }; - - const renderContractTableComponent = () => renderComponent(ContractsComponent, {imports: [AdminModule], providers: [{provide: AdminFacade, useValue: mockAdminFacade}]}) - let createElementSpy: jasmine.Spy - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ContractsComponent], - providers: [AdminFacade, AdminService] - }); - createElementSpy = spyOn(document, 'createElement').and.callThrough(); - + const mockAdminFacade = { + getContracts: jasmine.createSpy().and.returnValue(of(getContracts)), + }; + + const renderContractTableComponent = () => renderComponent(ContractsComponent, { + imports: [ AdminModule ], + providers: [ { provide: AdminFacade, useValue: mockAdminFacade } ], + }); + + let createElementSpy: jasmine.Spy; + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ ContractsComponent ], + providers: [ AdminFacade, AdminService ], }); + createElementSpy = spyOn(document, 'createElement').and.callThrough(); + + }); + + it('should create', async () => { + const { fixture } = await renderContractTableComponent(); + const { componentInstance } = fixture; + expect(componentInstance).toBeTruthy(); + }); + + it('should filter and change table config', async () => { + const { fixture } = await renderContractTableComponent(); + const { componentInstance } = fixture; + + const mockFilter = { + contractId: [ 'hello' ], + counterpartyAddress: [], + creationDate: [], + endDate: [], + state: [], + }; + const myPagination = { page: 0, pageSize: 10, sorting: [ '', null ] as TableHeaderSort }; + componentInstance.onTableConfigChange(myPagination); + expect(componentInstance.pagination.pageSize).toEqual(10); - it('should create', async () => { - const {fixture} = await renderContractTableComponent(); - const {componentInstance} = fixture; - expect(componentInstance).toBeTruthy(); - }); - - it('should filter and change table config', async () => { - const {fixture} = await renderContractTableComponent(); - const {componentInstance} = fixture; - - const mockFilter = { - contractId: ["hello"], - counterpartyAddress: [], - creationDate: [], - endDate: [], - state: [] - } - const myPagination = {page: 0, pageSize: 10, sorting: ['', null] as TableHeaderSort} - componentInstance.onTableConfigChange(myPagination) - expect(componentInstance.pagination.pageSize).toEqual(10); - - componentInstance.filterActivated(mockFilter); + componentInstance.filterActivated(mockFilter); - expect(JSON.stringify(componentInstance.contractFilter)).toContain("hello"); + expect(JSON.stringify(componentInstance.contractFilter)).toContain('hello'); - }); + }); - it('select a contract', async () => { - const {fixture} = await renderContractTableComponent(); - const {componentInstance} = fixture; - let mockSelectedContract = assembleContract(getContracts().content[0]); - componentInstance.multiSelection([mockSelectedContract]); - expect(componentInstance.selectedContracts.length).toEqual(1); - expect(componentInstance.selectedContracts[0].contractId).toEqual(mockSelectedContract.contractId) - }); + it('select a contract', async () => { + const { fixture } = await renderContractTableComponent(); + const { componentInstance } = fixture; + let mockSelectedContract = assembleContract(getContracts().content[0]); + componentInstance.multiSelection([ mockSelectedContract ]); + expect(componentInstance.selectedContracts.length).toEqual(1); + expect(componentInstance.selectedContracts[0].contractId).toEqual(mockSelectedContract.contractId); + }); - it('should export contracts as csv', async () => { - const {fixture} = await renderContractTableComponent(); - const {componentInstance} = fixture; + it('should export contracts as csv', async () => { + const { fixture } = await renderContractTableComponent(); + const { componentInstance } = fixture; - let mockSelectedContract = assembleContract(getContracts().content[0]); - componentInstance.multiSelection([mockSelectedContract]); + let mockSelectedContract = assembleContract(getContracts().content[0]); + componentInstance.multiSelection([ mockSelectedContract ]); - let convertSpy = spyOn(componentInstance, 'convertArrayOfObjectsToCSV'); - let downloadSpy = spyOn(componentInstance, 'downloadCSV') - componentInstance.exportContractsAsCSV(); - expect(convertSpy).toHaveBeenCalledWith([assembleContract(getContracts().content[0])]); - expect(downloadSpy).toHaveBeenCalled(); + let convertSpy = spyOn(componentInstance, 'convertArrayOfObjectsToCSV'); + let downloadSpy = spyOn(componentInstance, 'downloadCSV'); + componentInstance.exportContractsAsCSV(); + expect(convertSpy).toHaveBeenCalledWith([ assembleContract(getContracts().content[0]) ]); + expect(downloadSpy).toHaveBeenCalled(); - }); + }); - it('should convert data to csv', async () => { - const {fixture} = await renderContractTableComponent(); - const {componentInstance} = fixture; + it('should convert data to csv', async () => { + const { fixture } = await renderContractTableComponent(); + const { componentInstance } = fixture; - let result = componentInstance.convertArrayOfObjectsToCSV([getContracts().content[0]]) + let result = componentInstance.convertArrayOfObjectsToCSV([ getContracts().content[0] ]); - expect(result).toEqual("contractId,counterpartyAddress,creationDate,endDate,state,policy\n" + - "abc1,https://trace-x-edc-e2e-a.dev.demo.catena-x.net/api/v1/dsp,2024-02-26T13:38:07+01:00,,Finalized,jsontextaspolicy"); + expect(result).toEqual('contractId,counterpartyAddress,creationDate,endDate,state,policy\n' + + 'abc1,https://trace-x-edc-e2e-a.dev.demo.catena-x.net/api/v1/dsp,2024-02-26T13:38:07+01:00,,Finalized,jsontextaspolicy'); - }); - it('should download CSV file', async () => { - const {fixture} = await renderContractTableComponent(); - const {componentInstance} = fixture; - const csvContent = 'header1,header2\nvalue1,value2\nvalue3,value4'; // Sample CSV content - const fileName = 'test.csv'; + }); + it('should download CSV file', async () => { + const { fixture } = await renderContractTableComponent(); + const { componentInstance } = fixture; + const csvContent = 'header1,header2\nvalue1,value2\nvalue3,value4'; // Sample CSV content + const fileName = 'test.csv'; - // Mock the required browser APIs - const link = document.createElement('a'); - spyOn(link, 'setAttribute'); - spyOn(link, 'click'); - spyOn(document.body, 'appendChild').and.callThrough(); - spyOn(document.body, 'removeChild').and.callThrough(); + // Mock the required browser APIs + const link = document.createElement('a'); + spyOn(link, 'setAttribute'); + spyOn(link, 'click'); + spyOn(document.body, 'appendChild').and.callThrough(); + spyOn(document.body, 'removeChild').and.callThrough(); - createElementSpy.and.returnValue(link); + createElementSpy.and.returnValue(link); - componentInstance.downloadCSV(csvContent, fileName); + componentInstance.downloadCSV(csvContent, fileName); - // Check if a link was created with correct attributes - expect(createElementSpy).toHaveBeenCalledWith('a'); - expect(link.setAttribute).toHaveBeenCalledWith('href', jasmine.any(String)); - expect(link.setAttribute).toHaveBeenCalledWith('download', fileName); - expect(link.style.visibility).toBe('hidden'); + // Check if a link was created with correct attributes + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(link.setAttribute).toHaveBeenCalledWith('href', jasmine.any(String)); + expect(link.setAttribute).toHaveBeenCalledWith('download', fileName); + expect(link.style.visibility).toBe('hidden'); - // Check if the link was appended to the document body - expect(document.body.appendChild).toHaveBeenCalledWith(link); + // Check if the link was appended to the document body + expect(document.body.appendChild).toHaveBeenCalledWith(link); - // Check if the link was clicked - expect(link.click).toHaveBeenCalled(); + // Check if the link was clicked + expect(link.click).toHaveBeenCalled(); - // Ensure that the link is removed from the document body after being clicked - expect(document.body.removeChild).toHaveBeenCalledWith(link); - }); + // Ensure that the link is removed from the document body after being clicked + expect(document.body.removeChild).toHaveBeenCalledWith(link); + }); }); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.html b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.html new file mode 100644 index 0000000000..bd7b6a8a77 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.html @@ -0,0 +1,28 @@ + +
+
+

{{ title | i18n }}

+ close + +
+
+

{{ 'pageAdmin.policyManagement.deletionText' | i18n }}

+ + +

{{ item }}

+
+
+
+
+
+ + +
+ +
+
diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.scss b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.scss new file mode 100644 index 0000000000..bac5930034 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.scss @@ -0,0 +1,83 @@ +.dialog--content { + display: flex; + flex-direction: column; + width: 25vw; +} + +.dialog--header--container { + display: flex; + padding-bottom: 24px; +} + +.dialog--header--text { + font-weight: 500; + line-height: 1.6; + flex: 1; + font-family: LibreFranklin-SemiBold, -apple-system, + BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", + Arial, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 24px; + text-align: center; + margin-left: 32px; +} + +.mat-icon { + width: 32px; + height: 32px; + font-size: 30px; + margin-top: 4px; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; +} + +.dialog--content--container { + display: flex; + font-size: 20px; + line-height: 1.6; +} + +.dialog--actions--container { + background-color: rgb(237, 240, 244); + width: 100%; + display: flex; + justify-content: center; + + .dialog--actions--save--button { + display: inline-flex; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: center; + justify-content: center; + position: relative; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + outline: 0px; + border: 0px; + margin: 0px; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + text-decoration: none; + font-family: LibreFranklin, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-weight: 500; + line-height: 1.5; + color: rgb(255, 255, 255); + min-width: 64px; + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + + background-color: rgb(15, 113, 203); + border-radius: 50px; + font-size: 18px; + padding: 14px 32px; + box-shadow: rgba(15, 113, 203, 0.4) 0px 0px 0px 3px; + } + + .dialog--actions--save--button:hover, .dialog--actions--save--button:active, .dialog--actions--save--button:focus { + background-color: rgb(13, 85, 175) + } +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.spec.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.spec.ts new file mode 100644 index 0000000000..baf3a1946a --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.spec.ts @@ -0,0 +1,47 @@ +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { renderComponent } from '@tests/test-render.utils'; + +import { DeletionDialogComponent } from './deletion-dialog.component'; + +describe('DeletionDialogComponent', () => { + const dialogRefMock = { + close: jasmine.createSpy('close'), + }; + + const dataMock = { + policyIds: [ 'policy1', 'policy2' ], + title: 'Delete Policies', + }; + + const renderDeletionDialogComponent = () => renderComponent(DeletionDialogComponent, { + imports: [ MatDialogModule ], + providers: [ + { provide: MatDialogRef, useValue: dialogRefMock }, + { provide: MAT_DIALOG_DATA, useValue: dataMock }, + ], + }); + + it('should create', async () => { + const { fixture } = await renderDeletionDialogComponent(); + const { componentInstance } = fixture; + + expect(componentInstance).toBeTruthy(); + }); + + it('should initialize with provided data', async () => { + const { fixture } = await renderDeletionDialogComponent(); + const { componentInstance } = fixture; + + expect(componentInstance.policyIds).toEqual(dataMock.policyIds); + expect(componentInstance.title).toEqual(dataMock.title); + }); + + it('should call dialogRef.close(true) on save', async () => { + const { fixture } = await renderDeletionDialogComponent(); + const { componentInstance } = fixture; + + componentInstance.save(); + expect(dialogRefMock.close).toHaveBeenCalledWith(true); + }); +}); + diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.ts new file mode 100644 index 0000000000..8c1a2e7ea7 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component.ts @@ -0,0 +1,22 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-deletion-dialog', + templateUrl: './deletion-dialog.component.html', + styleUrls: [ './deletion-dialog.component.scss' ], +}) +export class DeletionDialogComponent { + policyIds: string[]; + title: string; + + constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any) { + this.policyIds = data?.policyIds; + this.title = data?.title; + } + + + save() { + this.dialogRef.close(true); + } +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.assembler.spec.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.assembler.spec.ts new file mode 100644 index 0000000000..e4d5d0ece2 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.assembler.spec.ts @@ -0,0 +1,121 @@ +import { PoliciesAssembler } from '@page/admin/presentation/policy-management/policies/policy.assembler'; +import { OperatorType, Policy, PolicyAction, PolicyEntry, PolicyResponseMap } from '@page/policies/model/policy.model'; + +// Mock data +const mockPolicy: Policy = { + policyId: 'policy123', + createdOn: '2024-01-01T00:00:00Z', + validUntil: '2024-12-31T23:59:59Z', + permissions: [ + { + action: 'use' as PolicyAction, + constraint: { + and: [ + { + leftOperand: 'left1', + operator: { '@id': OperatorType.EQ }, + operatorTypeResponse: OperatorType.EQ, + 'odrl:rightOperand': 'right1', + }, + ], + or: [ + { + leftOperand: 'left2', + operator: { '@id': OperatorType.NEQ }, + operatorTypeResponse: OperatorType.NEQ, + 'odrl:rightOperand': 'right2', + }, + ], + }, + }, + ], + +}; + +const mockPolicy2: Policy = { + policyId: 'policy123', + createdOn: '2024-01-01T00:00:00Z', + validUntil: '2024-12-31T23:59:59Z', + permissions: [ + { + action: 'use' as PolicyAction, + constraints: { + and: [ + { + leftOperand: 'left1', + operator: { '@id': OperatorType.EQ }, + operatorTypeResponse: OperatorType.EQ, + rightOperand: 'right1', + }, + ], + or: [ + { + leftOperand: 'left2', + operator: { '@id': OperatorType.NEQ }, + operatorTypeResponse: OperatorType.NEQ, + rightOperand: 'right2', + }, + ], + }, + }, + ], +}; + +const mockPolicyResponse: PolicyResponseMap = { + 'bpn123': [ + { + payload: { + '@context': { + odrl: 'test', + }, + '@id': 'entry123', + policy: mockPolicy, + }, + validUntil: '2024-01-01T00:00:00Z', + }, + ], +}; + +describe('PoliciesAssembler', () => { + it('should assemble policy', () => { + const assembledPolicy = PoliciesAssembler.assemblePolicy(mockPolicy2); + console.log(assembledPolicy.constraints); + expect(assembledPolicy.policyName).toBe(mockPolicy2.policyId); + expect(assembledPolicy.createdOn).toBe('2024-01-01T00:00'); + expect(assembledPolicy.validUntil).toBe('2024-12-31T23:59'); + expect(assembledPolicy.accessType).toBe('USE'); + expect(assembledPolicy.constraints).toEqual([ 'left1', '=', 'right1', 'left2', '!=', 'right2' ]); + + }); + + it('should map policy response to policy entry list', () => { + const policyEntryList = PoliciesAssembler.mapToPolicyEntryList(mockPolicyResponse); + expect(policyEntryList.length).toBe(1); + expect(policyEntryList[0].payload.policy.bpn).toBe('bpn123'); + expect(policyEntryList[0].payload.policy.policyName).toBe('entry123'); + }); + + it('should map display props to policy root level from policy entry', () => { + const policyEntry: PolicyEntry = { + validUntil: '2024-01-01T00:00:00Z', + payload: { + '@context': { + odrl: 'test', + }, + '@id': 'entry123', + policy: mockPolicy, + }, + }; + const constraints = PoliciesAssembler.mapDisplayPropsToPolicyRootLevelFromPolicyEntry(policyEntry); + expect(constraints).toEqual([ + 'left1', '=', 'right1', 'left2', '!=', 'right2', + ]); + }); + + it('should map display props to policy root level from policy', () => { + const constraints = PoliciesAssembler.mapDisplayPropsToPolicyRootLevelFromPolicy(mockPolicy2); + expect(constraints).toEqual([ + 'left1', '=', 'right1', 'left2', '!=', 'right2', + ]); + }); +}); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.html b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.html new file mode 100644 index 0000000000..bbf81bbe5f --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.spec.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.spec.ts new file mode 100644 index 0000000000..0b5735f10d --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.spec.ts @@ -0,0 +1,135 @@ +import { fakeAsync, tick } from '@angular/core/testing'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { RoleService } from '@core/user/role.service'; +import { AdminModule } from '@page/admin/admin.module'; +import { PoliciesFacade } from '@page/admin/presentation/policy-management/policies/policies.facade'; +import { Policy } from '@page/policies/model/policy.model'; +import { ToastService } from '@shared/components/toasts/toast.service'; +import { RenderResult } from '@testing-library/angular'; +import { renderComponent } from '@tests/test-render.utils'; +import { of, throwError } from 'rxjs'; +import { getPolicies } from '../../../../../../mocks/services/policy-mock/policy.model'; +import { PoliciesComponent } from './policies.component'; + +describe('PoliciesComponent', () => { + const policyFacadeMock = { + policies$: of(getPolicies()), + setPolicies: jasmine.createSpy('setPolicies'), + deletePolicies: jasmine.createSpy('deletePolicies').and.returnValue(of({})), + }; + + const toastServiceMock = { + success: jasmine.createSpy('success'), + error: jasmine.createSpy('error'), + }; + + const roleServiceMock = { + isAdmin: jasmine.createSpy('isAdmin').and.returnValue(true), + }; + + const routerMock = { + navigate: jasmine.createSpy('navigate'), + }; + + const matDialogMock = { + open: jasmine.createSpy('open').and.returnValue({ + afterClosed: () => of(true), + }), + }; + + const renderPoliciesComponent = async (): Promise> => await renderComponent(PoliciesComponent, { + imports: [ + AdminModule, + MatDialogModule, + RouterTestingModule, + ], + providers: [ + { provide: PoliciesFacade, useValue: policyFacadeMock }, + { provide: ToastService, useValue: toastServiceMock }, + { provide: RoleService, useValue: roleServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: MatDialog, useValue: matDialogMock }, + ], + }); + + it('should create', async () => { + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + + expect(componentInstance).toBeTruthy(); + }); + + it('should update selectedPolicies on multiSelection', async () => { + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + const mockPolicies = [ { policyId: '1' }, { policyId: '2' } ] as Policy[]; + + componentInstance.multiSelection(mockPolicies); + expect(componentInstance.selectedPolicies).toEqual(mockPolicies); + }); + + it('should navigate to detailed view on openDetailedView', async () => { + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + const mockPolicy = { policyId: '1' }; + + componentInstance.openDetailedView(mockPolicy); + expect(routerMock.navigate).toHaveBeenCalledWith([ 'admin/policies/1' ]); + }); + + it('should navigate to edit view on openEditView', async () => { + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + const mockPolicy = { policyId: '1' }; + + componentInstance.openEditView(mockPolicy); + expect(routerMock.navigate).toHaveBeenCalledWith([ 'admin/policies/edit/1' ]); + }); + + it('should open deletion dialog and delete policies on confirmation', fakeAsync(async () => { + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + componentInstance.selectedPolicies = [ { policyId: '1' }, { policyId: '2' } ] as Policy[]; + + componentInstance.openDeletionDialog(); + expect(matDialogMock.open).toHaveBeenCalled(); + + + tick(); // Simulate passage of time until afterClosed completes + + expect(policyFacadeMock.deletePolicies).toHaveBeenCalledWith(componentInstance.selectedPolicies); + expect(toastServiceMock.success).toHaveBeenCalled(); + expect(policyFacadeMock.setPolicies).toHaveBeenCalled(); + })); + + it('should call deletePolicies and handle success', fakeAsync(async () => { + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + componentInstance.selectedPolicies = [ { policyId: '1' }, { policyId: '2' } ] as Policy[]; + + componentInstance.deletePolicies(); + + tick(); // Simulate passage of time until the observable completes + + expect(policyFacadeMock.deletePolicies).toHaveBeenCalledWith(componentInstance.selectedPolicies); + expect(toastServiceMock.success).toHaveBeenCalled(); + expect(policyFacadeMock.setPolicies).toHaveBeenCalled(); + })); + + it('should call deletePolicies and handle error', fakeAsync(async () => { + policyFacadeMock.deletePolicies.and.returnValue(throwError('error')); + + const { fixture } = await renderPoliciesComponent(); + const componentInstance = fixture.componentInstance; + componentInstance.selectedPolicies = [ { policyId: '1' }, { policyId: '2' } ] as Policy[]; + + componentInstance.deletePolicies(); + + tick(); // Simulate passage of time until the observable completes + + expect(policyFacadeMock.deletePolicies).toHaveBeenCalledWith(componentInstance.selectedPolicies); + expect(toastServiceMock.error).toHaveBeenCalledWith('pageAdmin.policyManagement.deleteError'); + })); +}); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.ts new file mode 100644 index 0000000000..2b1935e920 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.component.ts @@ -0,0 +1,127 @@ +import { Component } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { RoleService } from '@core/user/role.service'; +import { KnownAdminRoutes } from '@page/admin/core/admin.model'; +import { DeletionDialogComponent } from '@page/admin/presentation/policy-management/deletion-dialog/deletion-dialog.component'; +import { PoliciesFacade } from '@page/admin/presentation/policy-management/policies/policies.facade'; +import { Policy } from '@page/policies/model/policy.model'; +import { TableType } from '@shared/components/multi-select-autocomplete/table-type.model'; +import { + CreateHeaderFromColumns, + TableConfig, + TableEventConfig, + TableHeaderSort, +} from '@shared/components/table/table.model'; +import { ToastService } from '@shared/components/toasts/toast.service'; +import { setMultiSorting } from '@shared/helper/table-helper'; +import { View } from '@shared/model/view.model'; +import { Observable, Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +@Component({ + selector: 'app-policies', + templateUrl: './policies.component.html', + styleUrls: [], +}) +export class PoliciesComponent { + policiesView$: Observable>; + tableConfig: TableConfig; + selectedPolicies: Policy[]; + policyFilter: any; + pagination: TableEventConfig; + multiSortList: TableHeaderSort[] = []; + ctrlKeyState: boolean = false; + deselectPartTrigger$ = new Subject(); + + constructor(public readonly policyFacade: PoliciesFacade, private readonly router: Router, private readonly toastService: ToastService, public dialog: MatDialog, private readonly roleService: RoleService) { + window.addEventListener('keydown', (event) => { + this.ctrlKeyState = setMultiSorting(event); + }); + window.addEventListener('keyup', (event) => { + this.ctrlKeyState = setMultiSorting(event); + }); + } + + ngOnInit() { + + this.pagination = { page: 0, pageSize: 10, sorting: [ '', null ] }; + this.tableConfig = { + displayedColumns: [ 'select', 'bpn', 'policyName', 'policyId', 'accessType', 'createdOn', 'validUntil', 'constraints', 'menu' ], + header: CreateHeaderFromColumns([ 'select', 'bpn', 'policyName', 'policyId', 'accessType', 'createdOn', 'validUntil', 'constraints', 'menu' ], 'pageAdmin.policies'), + menuActionsConfig: [ { + label: 'actions.edit', + icon: 'edit', + action: (selectedPolicy: Record) => this.openEditView(selectedPolicy), + isAuthorized: this.roleService.isAdmin(), + } ], + sortableColumns: { + select: false, + bpn: false, + policyName: false, + policyId: false, + accessType: false, + createdOn: false, + validUntil: false, + constraints: false, + menu: false, + }, + hasPagination: false, + }; + + this.policiesView$ = this.policyFacade.policies$; + this.policiesView$.pipe(take(2)).subscribe(data => { + if (data?.data?.length) { + return; + } else { + this.policyFacade.setPolicies(); + } + }); + + + } + + multiSelection(selectedPolicies: Policy[]) { + this.selectedPolicies = selectedPolicies; + } + + openDetailedView(selectedPolicy: Record) { + this.router.navigate([ 'admin/' + KnownAdminRoutes.POLICY_MANAGEMENT + '/' + selectedPolicy.policyId ]); + } + + openEditView(selectedPolicy: any) { + this.router.navigate([ 'admin/' + KnownAdminRoutes.POLICY_MANAGEMENT + '/edit/' + selectedPolicy.policyId ]); + } + + openDeletionDialog() { + const dialogRef = this.dialog.open(DeletionDialogComponent, { + data: { + policyIds: this.selectedPolicies.map(policy => policy.policyId), + title: 'pageAdmin.policyManagement.policyDeletion', + }, + }); + + dialogRef.afterClosed().subscribe(confirmation => { + if (confirmation) { + this.deletePolicies(); + this.deselectPartTrigger$.next(this.selectedPolicies); + } + }); + } + + + deletePolicies() { + this.policyFacade.deletePolicies(this.selectedPolicies).subscribe({ + next: value => { + this.toastService.success('pageAdmin.policyManagement.deleteSuccess'); + this.policyFacade.setPolicies(); + }, + error: err => { + this.toastService.error('pageAdmin.policyManagement.deleteError'); + }, + }); + } + + + protected readonly TableType = TableType; +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.facade.spec.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.facade.spec.ts new file mode 100644 index 0000000000..405e02ee72 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.facade.spec.ts @@ -0,0 +1,159 @@ +import { TestBed } from '@angular/core/testing'; +import { PoliciesState } from '@page/admin/presentation/policy-management/policies/policies.state'; // Adjust the path as necessary +import { PoliciesAssembler } from '@page/admin/presentation/policy-management/policies/policy.assembler'; // Adjust the path as necessary +import { Policy, PolicyEntry } from '@page/policies/model/policy.model'; // Adjust the path as necessary +import { PolicyService } from '@shared/service/policy.service'; // Adjust the path as necessary +import { of, throwError } from 'rxjs'; +import { PoliciesFacade } from './policies.facade'; // Adjust the path as necessary + +describe('PoliciesFacade', () => { + let facade: PoliciesFacade; + let policyServiceSpy: jasmine.SpyObj; + let policiesStateSpy: jasmine.SpyObj; + + beforeEach(() => { + const policyServiceMock = jasmine.createSpyObj('PolicyService', [ 'getPolicies', 'getPolicyById', 'deletePolicy', 'createPolicy', 'updatePolicy' ]); + const policiesStateMock = jasmine.createSpyObj('PoliciesState', [ 'policies$', 'selectedPolicy$', 'policies', 'selectedPolicy' ]); + + TestBed.configureTestingModule({ + providers: [ + PoliciesFacade, + { provide: PolicyService, useValue: policyServiceMock }, + { provide: PoliciesState, useValue: policiesStateMock }, + ], + }); + + facade = TestBed.inject(PoliciesFacade); + policyServiceSpy = TestBed.inject(PolicyService) as jasmine.SpyObj; + policiesStateSpy = TestBed.inject(PoliciesState) as jasmine.SpyObj; + }); + + it('should be created', () => { + expect(facade).toBeTruthy(); + }); + + describe('setPolicies', () => { + + it('should handle error when fetching policies', () => { + const mockError = new Error('Test error'); + policyServiceSpy.getPolicies.and.returnValue(throwError(mockError)); + + facade.setPolicies(); + + expect(policyServiceSpy.getPolicies).toHaveBeenCalled(); + expect(policiesStateSpy.policies).toEqual({ error: mockError }); + }); + }); + + describe('selectedPolicy', () => { + it('should get selected policy', () => { + const mockPolicy = { policyName: 'Test Policy' } as Policy; + policiesStateSpy.selectedPolicy = { data: mockPolicy }; + + expect(facade.selectedPolicy).toBe(mockPolicy); + }); + + it('should set selected policy', () => { + const mockPolicy = { policyName: 'Test Policy' } as Policy; + + facade.selectedPolicy = mockPolicy; + + expect(policiesStateSpy.selectedPolicy).toEqual({ data: mockPolicy }); + }); + }); + + describe('setSelectedPolicyById', () => { + it('should fetch and set selected policy by ID', () => { + const mockPolicy = { policyName: 'Test Policy' } as Policy; + policyServiceSpy.getPolicyById.and.returnValue(of(mockPolicy)); + spyOn(PoliciesAssembler, 'assemblePolicy').and.returnValue(mockPolicy); + + facade.setSelectedPolicyById('test-id'); + + expect(policyServiceSpy.getPolicyById).toHaveBeenCalledWith('test-id'); + expect(policiesStateSpy.selectedPolicy).toEqual({ data: mockPolicy }); + }); + + it('should handle error when fetching policy by ID', () => { + const mockError = new Error('Test error'); + policyServiceSpy.getPolicyById.and.returnValue(throwError(mockError)); + + facade.setSelectedPolicyById('test-id'); + + expect(policyServiceSpy.getPolicyById).toHaveBeenCalledWith('test-id'); + expect(policiesStateSpy.selectedPolicy).toEqual({ error: mockError }); + }); + }); + + describe('unsubscribePolicies', () => { + it('should unsubscribe from policies subscriptions', () => { + const mockPoliciesSubscription = jasmine.createSpyObj('Subscription', [ 'unsubscribe' ]); + const mockSelectedPoliciesSubscription = jasmine.createSpyObj('Subscription', [ 'unsubscribe' ]); + facade['policiesSubscription'] = mockPoliciesSubscription; + facade['selectedPoliciesSubscription'] = mockSelectedPoliciesSubscription; + + facade.unsubscribePolicies(); + + expect(mockPoliciesSubscription.unsubscribe).toHaveBeenCalled(); + expect(mockSelectedPoliciesSubscription.unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('deletePolicies', () => { + it('should call policyService.deletePolicies with policy IDs', () => { + const mockPolicies = [ { policyId: '1' }, { policyId: '2' } ] as Policy[]; + const mockDeleteResponse1 = of(null); + const mockDeleteResponse2 = of(null); + + policyServiceSpy.deletePolicy.withArgs('1').and.returnValue(mockDeleteResponse1); + policyServiceSpy.deletePolicy.withArgs('2').and.returnValue(mockDeleteResponse2); + + const result = facade.deletePolicies(mockPolicies); + + // Expect the policyService.deletePolicy to be called with each policyId + expect(policyServiceSpy.deletePolicy).toHaveBeenCalledWith('1'); + expect(policyServiceSpy.deletePolicy).toHaveBeenCalledWith('2'); + + // Expect the result to be an observable that emits an array of results + result.subscribe(responses => { + expect(responses).toEqual([ null, null ]); + }); + }); + }); + + describe('createPolicy', () => { + it('should call policyService.createPolicy with policy entry', () => { + const mockPolicyEntry = { + policyName: 'New Policy', + validUntil: null, + payload: null, + businessPartnerNumber: '', + } as PolicyEntry; + const mockCreateResponse = of(null); + policyServiceSpy.createPolicy.and.returnValue(mockCreateResponse); + + const result = facade.createPolicy(mockPolicyEntry); + + expect(policyServiceSpy.createPolicy).toHaveBeenCalledWith(mockPolicyEntry); + expect(result).toBe(mockCreateResponse); + }); + }); + + describe('updatePolicy', () => { + it('should call policyService.updatePolicy with policy entry', () => { + const mockPolicyEntry = { + policyName: 'New Policy', + validUntil: null, + payload: null, + businessPartnerNumber: '', + } as PolicyEntry; + const mockUpdateResponse = of(null); + policyServiceSpy.updatePolicy.and.returnValue(mockUpdateResponse); + + const result = facade.updatePolicy(mockPolicyEntry); + + expect(policyServiceSpy.updatePolicy).toHaveBeenCalledWith(mockPolicyEntry); + expect(result).toBe(mockUpdateResponse); + }); + }); +}); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.facade.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.facade.ts new file mode 100644 index 0000000000..dbb29869cd --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.facade.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@angular/core'; +import { PoliciesState } from '@page/admin/presentation/policy-management/policies/policies.state'; +import { PoliciesAssembler } from '@page/admin/presentation/policy-management/policies/policy.assembler'; +import { Policy, PolicyEntry } from '@page/policies/model/policy.model'; +import { View } from '@shared/model/view.model'; +import { PolicyService } from '@shared/service/policy.service'; +import { forkJoin, Observable, Subject, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class PoliciesFacade { + private policiesSubscription: Subscription; + private selectedPoliciesSubscription: Subscription; + private readonly unsubscribeTrigger = new Subject(); + + + constructor(private readonly policyService: PolicyService, + private readonly policiesState: PoliciesState, + ) { + } + + get policies$(): Observable> { + return this.policiesState.policies$; + } + + public setPolicies(): void { + this.policiesSubscription?.unsubscribe(); + this.policiesSubscription = this.policyService.getPolicies().pipe(map(response => { + return PoliciesAssembler.mapToPolicyEntryList(response).map(entry => entry.payload.policy).map(policy => PoliciesAssembler.assemblePolicy(policy)); + })).subscribe({ + next: data => (this.policiesState.policies = { data: data }), + error: error => (this.policiesState.policies = { error }), + }); + } + + get selectedPolicy$(): Observable> { + return this.policiesState.selectedPolicy$; + } + + get selectedPolicy(): Policy { + return this.policiesState.selectedPolicy?.data; + } + + + public set selectedPolicy(policy: Policy) { + this.policiesState.selectedPolicy = { data: policy }; + } + + public setSelectedPolicyById(policyId: string): void { + this.policyService.getPolicyById(policyId).subscribe({ + next: data => (this.policiesState.selectedPolicy = { data: PoliciesAssembler.assemblePolicy(data) }), + error: error => (this.policiesState.selectedPolicy = { error }), + }); + } + + + public unsubscribePolicies(): void { + this.policiesSubscription?.unsubscribe(); + this.selectedPoliciesSubscription?.unsubscribe(); + this.unsubscribeTrigger.next(); + } + + deletePolicies(selectedPolicies: Policy[]): Observable { + const deleteRequests = selectedPolicies.map(policy => + this.policyService.deletePolicy(policy.policyId), + ); + + return forkJoin(deleteRequests); + } + + createPolicy(policyEntry: PolicyEntry) { + return this.policyService.createPolicy(policyEntry); + } + + updatePolicy(policyEntry: PolicyEntry) { + return this.policyService.updatePolicy(policyEntry); + } +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.state.spec.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.state.spec.ts new file mode 100644 index 0000000000..6e39b110ed --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.state.spec.ts @@ -0,0 +1,65 @@ +import { TestBed } from '@angular/core/testing'; +import { Policy } from '@page/policies/model/policy.model'; +import { View } from '@shared/model/view.model'; +import { PoliciesState } from './policies.state'; + +describe('PoliciesState', () => { + let policiesState: PoliciesState; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ PoliciesState ], + }); + policiesState = TestBed.inject(PoliciesState); + }); + + it('should be created', () => { + expect(policiesState).toBeTruthy(); + }); + + it('should set and get policies correctly', () => { + const mockPolicies: Policy[] = [ + { policyId: '1', policyName: 'Policy 1', validUntil: '123', permissions: [], createdOn: '' }, + { policyId: '2', policyName: 'Policy 2', validUntil: '123', permissions: [], createdOn: '' }, + ]; + const mockView: View = { + data: mockPolicies, + loader: false, + error: undefined, + }; + + // Set policies + policiesState.policies = mockView; + + // Verify get policies$ + policiesState.policies$.subscribe((policies) => { + expect(policies).toEqual(mockView); + }); + }); + + it('should set and get selected policy correctly', () => { + const mockSelectedPolicy: Policy = { + policyId: '1', + policyName: 'Selected Policy', + validUntil: '123', + permissions: [], + createdOn: '', + }; + const mockView: View = { + data: mockSelectedPolicy, + loader: false, + error: undefined, + }; + + // Set selected policy + policiesState.selectedPolicy = mockView; + + // Verify get selectedPolicy$ + policiesState.selectedPolicy$.subscribe((selectedPolicy) => { + expect(selectedPolicy).toEqual(mockView); + }); + + // Verify get selectedPolicy + expect(policiesState.selectedPolicy).toEqual(mockView); + }); +}); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.state.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.state.ts new file mode 100644 index 0000000000..a3068f0902 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policies.state.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { Policy } from '@page/policies/model/policy.model'; +import { State } from '@shared/model/state'; +import { View } from '@shared/model/view.model'; +import { Observable } from 'rxjs'; + +@Injectable() +export class PoliciesState { + private readonly _policies$ = new State>({ loader: true }); + private readonly _selectedPolicy$: State> = new State>({ loader: true }); + + public get policies$(): Observable> { + return this._policies$.observable; + } + + public set policies({ data, loader, error }: View) { + const policiesView: View = { data, loader, error }; + this._policies$.update(policiesView); + } + + get selectedPolicy$(): Observable> { + return this._selectedPolicy$.observable; + } + + set selectedPolicy({ data, loader, error }: View) { + const selectedPolicyView: View = { data, loader, error }; + this._selectedPolicy$.update(selectedPolicyView); + } + + get selectedPolicy(): View { + return this._selectedPolicy$.snapshot; + } + +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policy.assembler.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policy.assembler.ts new file mode 100644 index 0000000000..0a41d888ef --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policies/policy.assembler.ts @@ -0,0 +1,169 @@ +import { CalendarDateModel } from '@core/model/calendar-date.model'; +import { + getOperatorTypeSign, + OperatorType, + Policy, + PolicyAction, + PolicyEntry, + PolicyResponseMap, +} from '@page/policies/model/policy.model'; +import { isNumber } from 'lodash-es'; + +export class PoliciesAssembler { + public static assemblePolicy(policy: Policy): Policy { + const formattedCreatedOn = new CalendarDateModel(policy.createdOn as string); + const formattedValidUntil = new CalendarDateModel(policy.validUntil as string); + return { + ...policy, + policyName: policy.policyId, + createdOn: isNumber(policy.createdOn) ? new Date(policy.createdOn as number * 1000).toISOString().slice(0, 19) + 'Z' : (formattedCreatedOn.isInitial() ? null : formattedCreatedOn.valueOf().toISOString().slice(0, 16)), + validUntil: isNumber(policy.validUntil) ? new Date(policy.validUntil as number * 1000).toISOString().slice(0, 19) + 'Z' : (formattedValidUntil.isInitial() ? null : formattedValidUntil.valueOf().toISOString().slice(0, 16)), + accessType: policy.permissions[0].action.toUpperCase() as PolicyAction, + constraints: policy.constraints ?? this.mapDisplayPropsToPolicyRootLevelFromPolicy(policy), + }; + } + + public static mapToPolicyEntryList(policyResponse: PolicyResponseMap): PolicyEntry[] { + const list: PolicyEntry[] = []; + for (const [ key, value ] of Object.entries(policyResponse)) { + value.forEach((entry) => { + entry.payload.policy.bpn = key; + entry.payload.policy.constraints = this.mapDisplayPropsToPolicyRootLevelFromPolicyEntry(entry); + list.push(entry); + }); + } + return list; + } + + public static mapDisplayPropsToPolicyRootLevelFromPolicyEntry(entry: PolicyEntry): string[] { + entry.payload.policy.policyName = entry.payload['@id']; + entry.payload.policy.accessType = entry.payload.policy.permissions[0].action; + let constrainsList = []; + entry.payload.policy.permissions.forEach(permission => { + permission.constraint?.and?.forEach((andConstraint, index) => { + constrainsList.push(andConstraint.leftOperand); + constrainsList.push(getOperatorTypeSign(OperatorType[andConstraint.operator['@id'].toUpperCase()])); + constrainsList.push(andConstraint['odrl:rightOperand']); + if (index !== permission.constraint.and.length - 1) { + constrainsList.push(' AND '); + } + }); + permission.constraint?.or?.forEach((orConstraint, index) => { + constrainsList.push(orConstraint.leftOperand); + constrainsList.push(getOperatorTypeSign(OperatorType[orConstraint.operator['@id'].toUpperCase()])); + constrainsList.push(orConstraint['odrl:rightOperand']); + if (index !== permission.constraint.or.length - 1) { + constrainsList.push(' OR '); + } + }); + }); + return constrainsList; + } + + public static mapDisplayPropsToPolicyRootLevelFromPolicy(policy: Policy): string[] { + let constrainsList = []; + policy.permissions.forEach((permission) => { + permission.constraints?.and?.forEach((andConstraint, index) => { + constrainsList.push(andConstraint.leftOperand); + constrainsList.push(getOperatorTypeSign(andConstraint.operatorTypeResponse)); + constrainsList.push(andConstraint.rightOperand); + if (index !== permission.constraints.and.length - 1) { + constrainsList.push(' AND '); + } + }); + permission.constraints?.or?.forEach((orConstraint, index) => { + constrainsList.push(orConstraint.leftOperand); + constrainsList.push(getOperatorTypeSign(orConstraint.operatorTypeResponse)); + constrainsList.push(orConstraint.rightOperand); + if (index !== permission.constraints.or.length - 1) { + constrainsList.push(' OR '); + } + }); + }); + return constrainsList; + } + + /** + * This Feature is commented out for now, because uploading/downloading Templates/Policies is + * currently not a requirement but could be one in future. + */ + /* + public static validatePoliciesTemplate(data: any) { + + if (typeof data !== 'object' || data === null || !Array.isArray(data[Object.keys(data)[0]])) { + return false; + } + + for (const entry of data[Object.keys(data)[0]]) { + if (typeof entry.validUntil !== 'string') { + return false; + } + + const payload = entry.payload; + if (typeof payload !== 'object' || payload === null) { + return false; + } + + const context = payload['@context']; + if (typeof context !== 'object' || context === null) { + return false; + } + + if (typeof payload['@id'] !== 'string') { + return false; + } + + const policy = payload.policy; + if (typeof policy !== 'object' || policy === null) { + return false; + } + + if (typeof policy.policyId !== 'string' || + typeof policy.createdOn !== 'string' || + typeof policy.validUntil !== 'string' || + !Array.isArray(policy.permissions)) { + return false; + } + + for (const permission of policy.permissions) { + if (typeof permission.action !== 'string') { + return false; + } + + const constraint = permission.constraint; + if (typeof constraint !== 'object' || constraint === null || !Array.isArray(constraint.and) || (constraint.or !== null && !Array.isArray(constraint.or))) { + return false; + } + + if (constraint.and !== null) { + for (const andConstraint of constraint.and) { + if (typeof andConstraint.leftOperand !== 'string' || + typeof andConstraint.operator !== 'object' || + andConstraint.operator === null || + typeof andConstraint.operator['@id'] !== 'string' || + typeof andConstraint['odrl:rightOperand'] !== 'string') { + return false; + } + } + } + + if (constraint.or !== null) { + for (const orConstraint of constraint.or) { + if (typeof orConstraint.leftOperand !== 'string' || + typeof orConstraint.operator !== 'object' || + orConstraint.operator === null || + typeof orConstraint.operator['@id'] !== 'string' || + typeof orConstraint['odrl:rightOperand'] !== 'string') { + return false; + } + } + } + } + } + + return true; + } + + */ + +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-data.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-data.ts new file mode 100644 index 0000000000..f812fce838 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-data.ts @@ -0,0 +1,36 @@ +import { ConstraintLogicType, OperatorType } from '@page/policies/model/policy.model'; + +export const OperatorTypesAsSelectOptionsList: any[] = Object.keys(OperatorType).map(key => { + let convertedOperatorType = ''; + switch (key) { + case OperatorType.EQ: + convertedOperatorType = '='; + break; + case OperatorType.NEQ: + convertedOperatorType = '!='; + break; + case OperatorType.LT: + convertedOperatorType = '<'; + break; + case OperatorType.GT: + convertedOperatorType = '>'; + break; + case OperatorType.LTEQ: + convertedOperatorType = '<='; + break; + case OperatorType.GTEQ: + convertedOperatorType = '>='; + break; + default: + convertedOperatorType = key; + } + return { + label: convertedOperatorType, value: convertedOperatorType, + }; +}); + +export const ConstraintLogicTypeAsSelectOptionsList: any[] = Object.keys(ConstraintLogicType).map(key => { + return { + label: key, value: key, + }; +}); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.html b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.html new file mode 100644 index 0000000000..a1d3ce7c32 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.html @@ -0,0 +1,247 @@ + + +

{{ "pageAdmin.policyManagement.policy"| i18n }} {{ "pageAdmin.policyManagement." + viewMode | i18n }}

+
+
+ +
+
+ +
+ arrow_back + {{ 'actions.goBack' | i18n }} +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ + +
+ +
+ +
+

{{ 'pageAdmin.policyManagement.policyConstraints' | i18n }}

+ +
+ +
+

{{ 'pageAdmin.policyManagement.constraints' | i18n }} {{ '(' + constraints.length + ')' }}

+
+ +
+ +
+
+
+ {{ "pageAdmin.policyManagement.logic" | i18n }} +
+ {{ "pageAdmin.policyManagement.logicTypeHint" | i18n }} +
+
+ +
+
+
+
+ {{ "pageAdmin.policyManagement.leftOperand" | i18n }} +
+
+ {{ "pageAdmin.policyManagement.operator" | i18n }} +
+
+ {{ "pageAdmin.policyManagement.rightOperand" | i18n }} +
+ {{ "pageAdmin.policyManagement.rightOperandHint" | i18n }} + +
+
+
+
+ + + +
+ + +
+
+ + + +
+
+ + +
+
+
+
+ +
+
+ + +
+
diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.scss b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.scss new file mode 100644 index 0000000000..89c5e08721 --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.scss @@ -0,0 +1,52 @@ +.file-input { + background-color: #ececec; + border-radius: 5px; + width: 300px; + padding: 8px; +} + +.hinttext { + color: #878787; +} + +.details-container { + width: 50%; +} + +.constraints--header--label { + font-size: 14px; + font-weight: 600; +} + +.constraints--header--sub-label { + font-size: 12px; + font-weight: normal; + font-family: Catena-X Light, sans-serif; + color: #777777; + + &--logicType { + font-size: 12px; + font-weight: normal; + font-family: Catena-X Light, sans-serif; + color: #777777; + width: 200px; + @media(max-width: 1137px) { + height: 54px; + } + } +} + +.constraints--header--container { + width: calc(100% - 192px); +} + +.label-row-view-mode { + width: 100%; +} + +.sub-label { + font-size: 12px; + font-weight: normal; + font-family: Catena-X Light, sans-serif; + color: #777777; +} diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.spec.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.spec.ts new file mode 100644 index 0000000000..8c71377cbc --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.spec.ts @@ -0,0 +1,255 @@ +import { APP_INITIALIZER } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { PoliciesFacade } from '@page/admin/presentation/policy-management/policies/policies.facade'; +import { PoliciesAssembler } from '@page/admin/presentation/policy-management/policies/policy.assembler'; +import { OperatorType, Policy, PolicyAction } from '@page/policies/model/policy.model'; +import { ToastService } from '@shared/components/toasts/toast.service'; +import { ViewMode } from '@shared/model/view.model'; +import { renderComponent } from '@tests/test-render.utils'; +import { I18NEXT_SERVICE, ITranslationService } from 'angular-i18next'; +import { of, Subject } from 'rxjs'; +import { PolicyEditorComponent } from './policy-editor.component'; + +describe('PolicyEditorComponent', () => { + let mockPoliciesFacade: Partial; + let mockToastService: Partial; + let mockRouter: Partial; + let mockRoute: Partial; + let selectedPolicySubject: Subject<{ data: Policy }>; + + const mockPolicy: Policy = { + policyName: 'policy123', + policyId: 'policy123', + createdOn: '2024-01-01T00:00:00Z', + validUntil: '2024-12-31T23:59:59Z', + bpn: 'Test BPN', + permissions: [ + { + action: 'use' as PolicyAction, + constraint: { + and: [ + { + leftOperand: 'left1', + operator: { '@id': OperatorType.EQ }, + operatorTypeResponse: OperatorType.EQ, + 'odrl:rightOperand': 'right1', + }, + ], + or: [ + { + leftOperand: 'left2', + operator: { '@id': OperatorType.NEQ }, + operatorTypeResponse: OperatorType.NEQ, + 'odrl:rightOperand': 'right2', + }, + ], + }, + }, + ], + constraints: [], + }; + + beforeEach(() => { + selectedPolicySubject = new Subject(); + mockPoliciesFacade = { + selectedPolicy$: selectedPolicySubject.asObservable(), + setSelectedPolicyById: jasmine.createSpy(), + createPolicy: jasmine.createSpy().and.returnValue(of({})), + updatePolicy: jasmine.createSpy().and.returnValue(of({})), + }; + mockToastService = { + success: jasmine.createSpy(), + error: jasmine.createSpy(), + }; + mockRouter = { + navigate: jasmine.createSpy(), + url: 'admin/policies/create', + }; + + + mockRoute = { + snapshot: { + paramMap: convertToParamMap({ policyId: '1' }), + url: [], + params: {}, + queryParams: {}, + fragment: null, + data: {}, + outlet: 'primary', + component: PolicyEditorComponent, + root: null, + parent: null, + firstChild: null, + children: [], + pathFromRoot: [], + toString: () => '', + routeConfig: null, + title: '', + queryParamMap: convertToParamMap({}), + }, + }; + }); + + const renderPolicyEditorComponent = () => + renderComponent(PolicyEditorComponent, { + imports: [ RouterTestingModule ], + providers: [ + { provide: PoliciesFacade, useValue: mockPoliciesFacade }, + { provide: ToastService, useValue: mockToastService }, + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockRoute }, + FormBuilder, + { + provide: APP_INITIALIZER, + useFactory: (i18next: ITranslationService) => { + return () => + i18next.init({ + lng: 'en', + supportedLngs: [ 'en', 'de' ], + resources: {}, + }); + }, + deps: [ I18NEXT_SERVICE ], + multi: true, + }, + ], + }); + + it('should create', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + expect(componentInstance).toBeTruthy(); + }); + + it('should initialize the form in create mode', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + expect(componentInstance.viewMode).toBe(ViewMode.CREATE); + expect(componentInstance.policyForm).toBeTruthy(); + expect(componentInstance.policyForm.get('policyName').valid).toBeFalsy(); // Validators required + expect(componentInstance.policyForm.get('bpns').valid).toBeFalsy(); // Validators required + expect(componentInstance.policyForm.get('validUntil').valid).toBeFalsy(); // Validators required + }); + + it('should initialize the view mode correctly', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + Object.defineProperty(mockRouter, 'url', { + get: jasmine.createSpy().and.returnValue('admin/policies/create'), + }); + expect(componentInstance.initializeViewMode()).toBe(ViewMode.CREATE); + Object.defineProperty(mockRouter, 'url', { + get: jasmine.createSpy().and.returnValue('admin/policies/edit/1'), + }); + + expect(componentInstance.initializeViewMode()).toBe(ViewMode.EDIT); + + Object.defineProperty(mockRouter, 'url', { + get: jasmine.createSpy().and.returnValue('admin/policies/1'), + }); + + expect(componentInstance.initializeViewMode()).toBe(ViewMode.VIEW); + }); + + it('should add and remove constraints', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + expect(componentInstance.constraints.length).toBe(1); + + componentInstance.addConstraintFormGroup(); + expect(componentInstance.constraints.length).toBe(2); + + componentInstance.removeConstraintFormGroup(0); + expect(componentInstance.constraints.length).toBe(1); + }); + + it('should move constraints up and down', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + componentInstance.addConstraintFormGroup(); + componentInstance.addConstraintFormGroup(); + + componentInstance.constraints.at(0).get('leftOperand').setValue('constraint 1'); + componentInstance.constraints.at(1).get('leftOperand').setValue('constraint 2'); + + componentInstance.moveConstraintDown(0); + expect(componentInstance.constraints.at(0).get('leftOperand').value).toBe('constraint 2'); + expect(componentInstance.constraints.at(1).get('leftOperand').value).toBe('constraint 1'); + + componentInstance.moveConstraintUp(1); + expect(componentInstance.constraints.at(0).get('leftOperand').value).toBe('constraint 1'); + expect(componentInstance.constraints.at(1).get('leftOperand').value).toBe('constraint 2'); + }); + + it('should navigate back', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + componentInstance.navigateBack(); + expect(mockRouter.navigate).toHaveBeenCalledWith([ 'admin/policies' ]); + }); + + it('should save policy in create mode', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + componentInstance.policyForm.patchValue({ + policyName: 'Test Policy', + validUntil: new Date().toISOString(), + bpns: 'BPN0001', + accessType: 'access', + constraintLogicType: 'AND', + }); + componentInstance.addConstraintFormGroup(); + componentInstance.constraints.at(0).patchValue({ + leftOperand: 'leftOperand', + operator: '=', + rightOperand: 'rightOperand', + }); + + componentInstance.savePolicy(); + expect(mockPoliciesFacade.createPolicy).toHaveBeenCalled(); + expect(mockToastService.success).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalled(); + }); + + it('should update policy form correctly in create mode', async () => { + const { fixture } = await renderPolicyEditorComponent(); + const { componentInstance } = fixture; + componentInstance.selectedPolicy = mockPolicy; + const policy: Policy = PoliciesAssembler.assemblePolicy(mockPolicy); + + componentInstance.viewMode = ViewMode.CREATE; + componentInstance.policyForm = componentInstance.fb.group({ + policyName: '', + validUntil: null, + bpns: '', + accessType: '', + constraintLogicType: '', + constraints: componentInstance.fb.array([]), + }); + + componentInstance.updatePolicyForm(policy); + + // Assert the form values are updated correctly + expect(componentInstance.policyForm.getRawValue().policyName).toBe('policy123'); + expect(componentInstance.policyForm.getRawValue().validUntil).toBe(policy.validUntil); + expect(componentInstance.policyForm.getRawValue().bpns).toBe('Test BPN'); + expect(componentInstance.policyForm.getRawValue().accessType).toBe('USE'); + expect(componentInstance.policyForm.getRawValue().constraintLogicType).toBe('AND'); + expect(componentInstance.policyForm.getRawValue().constraints.length).toBe(1); + + componentInstance.viewMode = ViewMode.VIEW; + + componentInstance.updatePolicyForm(policy); + expect(componentInstance.policyForm.disabled).toBe(true); + + componentInstance.viewMode = ViewMode.EDIT; + componentInstance.updatePolicyForm(policy); + expect(componentInstance.policyForm.get('validUntil').disabled).toBe(false); + expect(componentInstance.policyForm.get('bpns').disabled).toBe(false); + expect(componentInstance.constraints.disabled).toBe(true); + + }); + +}); diff --git a/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.ts b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.ts new file mode 100644 index 0000000000..62bcd3b0bc --- /dev/null +++ b/frontend/src/app/modules/page/admin/presentation/policy-management/policy-editor/policy-editor.component.ts @@ -0,0 +1,324 @@ +import { Component } from '@angular/core'; +import { FormArray, FormBuilder, FormControl, FormGroup, ValidationErrors, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { bpnListRegex, bpnRegex } from '@page/admin/presentation/bpn-configuration/bpn-configuration.component'; +import { PoliciesFacade } from '@page/admin/presentation/policy-management/policies/policies.facade'; +import { + ConstraintLogicTypeAsSelectOptionsList, + OperatorTypesAsSelectOptionsList, +} from '@page/admin/presentation/policy-management/policy-editor/policy-data'; +import { + ConstraintLogicType, + getOperatorType, + getOperatorTypeSign, + OperatorType, + Policy, + PolicyAction, + PolicyConstraint, + PolicyEntry, +} from '@page/policies/model/policy.model'; +import { BaseInputHelper } from '@shared/abstraction/baseInput/baseInput.helper'; +import { ToastService } from '@shared/components/toasts/toast.service'; +import { ViewMode } from '@shared/model/view.model'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-policy-editor', + templateUrl: './policy-editor.component.html', + styleUrls: [ './policy-editor.component.scss' ], +}) +export class PolicyEditorComponent { + + selectedPolicy: Policy; + selectedPolicySubscription: Subscription; + viewMode: ViewMode; + + templateFile: File | null = null; + templateFileName: string = ''; + policyForm: FormGroup; + minDate: Date = new Date(); + templateError: string = ''; + + constructor(private router: Router, private route: ActivatedRoute, public policyFacade: PoliciesFacade, public fb: FormBuilder, private toastService: ToastService) { + } + + get constraints() { + return this.policyForm.get('constraints') as FormArray; + } + + ngOnInit() { + this.viewMode = this.initializeViewMode(); + + + + this.policyForm = this.fb.group({ + policyName: new FormControl('', [ Validators.required, Validators.minLength(8), Validators.maxLength(40) ]), + validUntil: new FormControl('', [ Validators.required, this.futureDateValidator ]), + bpns: new FormControl('', [ Validators.required, this.viewMode === ViewMode.CREATE ? BaseInputHelper.getCustomPatternValidator(bpnRegex, 'bpn') : BaseInputHelper.getCustomPatternValidator(bpnListRegex, 'bpn') ]), + accessType: new FormControl(PolicyAction.ACCESS), + constraints: this.fb.array([]), + constraintLogicType: new FormControl(ConstraintLogicType.AND), + }); + + if (this.viewMode !== ViewMode.CREATE) { + this.setSelectedPolicy(); + this.selectedPolicySubscription = this.policyFacade.selectedPolicy$.subscribe(next => { + this.selectedPolicy = next?.data; + if (next?.data) { + console.log(next.data); + this.updatePolicyForm(this.selectedPolicy); + } + }); + + } else { + this.addConstraintFormGroup(); + } + + } + + initializeViewMode(): ViewMode { + const url = this.router.url; + if (url.includes('create')) { + return ViewMode.CREATE; + } else if (url.includes('edit')) { + return ViewMode.EDIT; + } else { + return ViewMode.VIEW; + } + } + + private setSelectedPolicy(): void { + this.policyFacade.setSelectedPolicyById(this.route.snapshot.paramMap.get('policyId')); + } + + addConstraintFormGroup() { + this.constraints.push(this.fb.group({ + leftOperand: new FormControl('', [ Validators.required ]), + operator: new FormControl('='), + rightOperand: new FormControl('', [ Validators.required ]), + })); + } + + removeConstraintFormGroup(index: number) { + this.constraints.removeAt(index); + } + + moveConstraintUp(index: number): void { + if (index <= 0) { + return; + } + const constraints = this.policyForm.get('constraints') as FormArray; + const constraint = constraints.at(index); + constraints.removeAt(index); + constraints.insert(index - 1, constraint); + } + + moveConstraintDown(index: number): void { + const constraints = this.policyForm.get('constraints') as FormArray; + if (index >= constraints.length - 1) { + return; + } + const constraint = constraints.at(index); + constraints.removeAt(index); + constraints.insert(index + 1, constraint); + } + + + navigateBack() { + this.router.navigate([ 'admin/policies' ]); + } + + savePolicy() { + const policyEntry = this.mapPolicyFormToPolicyEntry(); + const request = this.viewMode === ViewMode.EDIT ? this.policyFacade.updatePolicy(policyEntry) : this.policyFacade.createPolicy(policyEntry); + request.subscribe({ + next: () => { + this.toastService.success('pageAdmin.policyManagement.successMessage'); + this.router.navigate([ 'admin', 'policies', policyEntry.payload.policy.policyId ]); + }, + error: () => this.toastService.error('pageAdmin.policyManagement.errorMessage'), + }); + } + + /** + * This Feature is commented out for now, because uploading/downloading Templates/Policies is + * currently not a requirement but could be one in the future. + */ + /* + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.templateFile = input.files[0]; + this.templateFileName = this.templateFile.name; + this.templateError = ''; + } + } + + */ + + navigateToEditView() { + this.router.navigate([ 'admin/policies/', 'edit', this.selectedPolicy.policyId ]); + } + + /** + * This Feature is commented out for now, because uploading/downloading Templates/Policies is + * currently not a requirement but could be one in the future. + */ + /* + downloadTemplateAsJsonFile() { + const policy = this.mapPolicyFormToPolicyEntry(); + const data = JSON.stringify(policy, null, 2); + const blob = new Blob([ data ], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = policy.payload.policy.policyId.length ? 'policy-template-' + policy.payload.policy.policyId : 'policy-template'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + } + + + + applyTemplate() { + if (!this.templateFile) { + return; + } + const reader = new FileReader(); + + reader.onload = () => { + const fileContent = reader.result; + if (typeof fileContent === 'string') { + if (!PoliciesAssembler.validatePoliciesTemplate(JSON.parse(fileContent))) { + this.templateError = 'pageAdmin.policyManagement.templateErrorMessage'; + return; + } + let policyEntry = PoliciesAssembler.mapToPolicyEntryList(JSON.parse(fileContent)); + let policy = PoliciesAssembler.assemblePolicy(policyEntry[0].payload.policy); + this.toastService.success('pageAdmin.policyManagement.changeSuccessMessage'); + this.updatePolicyForm(policy); + } + }; + + reader.onerror = () => { + this.toastService.error(reader.error?.message); + }; + + reader.readAsText(this.templateFile); + + } + */ + updatePolicyForm(policy: Policy) { + + const isFromTemplate = !policy?.permissions[0]?.constraints; + this.policyForm.patchValue({ + policyName: policy?.policyName, + validUntil: policy?.validUntil, + bpns: policy?.bpn ?? policy?.businessPartnerNumber, + accessType: policy?.accessType, + constraintLogicType: policy?.permissions[0]?.constraints?.and?.length ? ConstraintLogicType.AND : ConstraintLogicType.OR, + }); + if (isFromTemplate) { + this.policyForm.patchValue({ constraintLogicType: policy?.permissions[0]?.constraint?.and?.length ? ConstraintLogicType.AND : ConstraintLogicType.OR }); + } + + let permissionList = policy?.permissions[0]?.constraints?.and?.length ? policy?.permissions[0]?.constraints?.and : policy?.permissions[0]?.constraints?.or; + if (!permissionList) { + permissionList = policy?.permissions[0]?.constraint?.and?.length ? policy?.permissions[0]?.constraint?.and : policy?.permissions[0]?.constraint?.or; + } + + let constraintsList = permissionList.map((constraint) => this.fb.group({ + leftOperand: this.fb.control(constraint.leftOperand, [ Validators.required ]), + operator: this.fb.control(constraint?.operator?.['@id'] ? getOperatorTypeSign(OperatorType[constraint?.operator?.['@id'].toUpperCase()]) : getOperatorTypeSign(constraint?.operatorTypeResponse)), + rightOperand: this.fb.control(constraint['odrl:rightOperand'] ?? constraint.rightOperand, [ Validators.required ]), + })); + + + this.policyForm.setControl('constraints', this.fb.array(constraintsList)); + + + if (this.viewMode === ViewMode.VIEW) { + this.policyForm.disable(); + } + + if (this.viewMode === ViewMode.EDIT) { + this.policyForm.disable(); + this.constraints.controls.forEach(control => { + control.disable(); + }); + this.policyForm.get('validUntil').enable(); + this.policyForm.get('bpns').enable(); + } + + } + + mapPolicyFormToPolicyEntry(): PolicyEntry { + let policyEntry: PolicyEntry; + let policyConstraints: PolicyConstraint[]; + + policyConstraints = this.policyForm.get('constraints').getRawValue().map((constraint) => { + return { + 'odrl:leftOperand': constraint.leftOperand, + 'odrl:operator': { + '@id': 'odrl:' + getOperatorType(constraint.operator).toLowerCase(), + }, + 'odrl:rightOperand': constraint.rightOperand, + }; + }); + + policyEntry = { + validUntil: this.policyForm.get('validUntil').getRawValue() + ':00.000000000Z', + businessPartnerNumber: this.viewMode === ViewMode.CREATE ? this.policyForm.get('bpns').getRawValue() : this.policyForm.get('bpns').getRawValue()?.trim()?.split(','), + payload: { + '@context': { + odrl: 'http://www.w3.org/ns/odrl/2/', + }, + '@id': this.policyForm.get('policyName').getRawValue(), + policy: { + policyId: this.policyForm.get('policyName').getRawValue(), + createdOn: new Date(Date.now()).toISOString().replace('Z', '000000Z'), + validUntil: this.policyForm.get('validUntil').getRawValue() + ':00.000000000Z', + permissions: [ + { + action: this.policyForm.get('accessType').getRawValue().toLowerCase(), + constraint: { + and: this.policyForm.get('constraintLogicType').getRawValue() === ConstraintLogicType.AND ? policyConstraints : null, + or: this.policyForm.get('constraintLogicType').getRawValue() === ConstraintLogicType.OR ? policyConstraints : null, + }, + }, + ], + }, + }, + }; + + if (policyEntry.payload.policy.permissions[0].constraint?.and?.length) { + delete policyEntry.payload.policy.permissions[0].constraint?.or; + } else { + delete policyEntry.payload.policy.permissions[0].constraint?.and; + } + + return policyEntry; + } + + private futureDateValidator = (control: FormControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + + const currentDate = new Date(); + const inputDate = new Date(control.value); + if (inputDate < currentDate) { + return { pastDate: true }; + } + return null; + }; + + + protected readonly ViewMode = ViewMode; + protected readonly OperatorTypesAsSelectOptionsList = OperatorTypesAsSelectOptionsList; + protected readonly ConstraintLogicTypeAsSelectOptionsList = ConstraintLogicTypeAsSelectOptionsList; +} + diff --git a/frontend/src/app/modules/page/policies/model/policy.model.ts b/frontend/src/app/modules/page/policies/model/policy.model.ts index 1d68e5e0f1..f173ced62b 100644 --- a/frontend/src/app/modules/page/policies/model/policy.model.ts +++ b/frontend/src/app/modules/page/policies/model/policy.model.ts @@ -1,3 +1,5 @@ +import { CalendarDateModel } from '@core/model/calendar-date.model'; + /******************************************************************************** * Copyright (c) 2022, 2023, 2024 Contributors to the Eclipse Foundation * @@ -16,43 +18,128 @@ * * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -// TODO: Decide if long term a Policy state, facade, ResponseType and Assembler is needed -// TODO: Align with BE to a first valid Policy Model, changes seem to be happen frequently +// RESPONSE + +export interface PolicyResponseMap { + [key: string]: PolicyEntry[]; +} + +export interface PolicyEntry { + validUntil: string; + payload: PolicyPayload; + businessPartnerNumber?: string[] | string; + policyIds?: string[]; +} + +export interface PolicyPayload { + '@context': { + odrl: string; + }; + '@id': string; + policy: Policy; +} + + export interface Policy { + // props in response policyId: string; - createdOn: string; - validUntil: string; - permissions?: PolicyPermission[]; + createdOn: CalendarDateModel | string | number; + validUntil: CalendarDateModel | string | number; + permissions: PolicyPermission[]; + + // additional props + policyName?: string; + bpn?: string; + constraints?: string[] + accessType?: PolicyAction, + businessPartnerNumber?: string | string[] + } export interface PolicyPermission { - action: PolicyType; - constraints?: Constraint[]; + action: PolicyAction; + constraint?: { + and: PolicyConstraint[]; + or: null | PolicyConstraint[]; + xone?: PolicyConstraint[]; + andsequence?: PolicyConstraint[]; + }; + constraints?: { + and: PolicyConstraint[]; + or: null | PolicyConstraint[]; + xone?: PolicyConstraint[]; + andsequence?: PolicyConstraint[]; + }; } -export enum PolicyType { - ACCESS="ACCESS", - USE="USE" +export enum PolicyAction { + ACCESS = 'ACCESS', + USE = 'USE' } -export interface Constraint { - leftOperand: string; - operator: OperatorType; - rightOperand: string[]; +export interface PolicyConstraint { + leftOperand?: string; + 'odrl:leftOperand'?: string; + operatorTypeResponse?: OperatorType; + operator?: { '@id': OperatorType }; + 'odrl:operator'?: { '@id': OperatorType }; + rightOperand?: string; + 'odrl:rightOperand'?: string; } export enum OperatorType { - EQ = 'eq', - NEQ = 'neq', - LT = 'lt', - GT = 'gt', - IN = 'in', - LTEQ = 'lteq', - GTEQ = 'gteq', - ISA = 'isA', - HASPART = 'hasPart', - ISPARTOF = 'isPartOf', - ISONEOF = 'isOneOf', - ISALLOF = 'isAllOf', - ISNONEOF = 'isNoneOf', + EQ = 'EQ', + NEQ = 'NEQ', + LT = 'LT', + GT = 'GT', + LTEQ = 'LTEQ', + GTEQ = 'GTEQ', + IN = 'IN', + ISA = 'ISA', + HASPART = 'HASPART', + ISPARTOF = 'ISPARTOF', + ISONEOF = 'ISONEOF', + ISALLOF = 'ISALLOF', + ISNONEOF = 'ISNONEOF', +} + +const OperatorSignsToTypes: { [key: string]: OperatorType } = { + '=': OperatorType.EQ, + '!=': OperatorType.NEQ, + '<': OperatorType.LT, + '>': OperatorType.GT, + '<=': OperatorType.LTEQ, + '>=': OperatorType.GTEQ, + ...OperatorType, + // Add more mappings as needed +}; + +export function getOperatorType(sign: string): OperatorType | undefined { + return OperatorSignsToTypes[sign]; +} + +export function getOperatorTypeSign(type: OperatorType): string { + switch (type) { + case OperatorType.EQ: + return '='; + case OperatorType.NEQ: + return '!='; + case OperatorType.LT: + return '<'; + case OperatorType.GT: + return '>'; + case OperatorType.LTEQ: + return '<='; + case OperatorType.GTEQ: + return '>='; + default: + return type.toString(); + } +} + +export enum ConstraintLogicType { + AND = 'AND', + OR = 'OR', + XONE = 'XONE', + ANDSEQUENCE = 'ANDSEQUENCE' } diff --git a/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.spec.ts b/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.spec.ts index 179dec1717..cdf60e65d5 100644 --- a/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.spec.ts +++ b/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.spec.ts @@ -27,7 +27,7 @@ describe('AssetPublisherComponent', () => { const { fixture } = await renderAssetPublisherComponent(); const { componentInstance } = fixture; - const dummyPolicy: Policy = { policyId: 'id-1', createdOn: 'testdate', validUntil: 'testdate' }; + const dummyPolicy: Policy = { policyId: 'id-1', createdOn: 'testdate', validUntil: 'testdate', permissions: [] }; policyServiceSpy.publishAssets.and.returnValue(of({})); policyServiceSpy.getPolicies.and.returnValue(of([dummyPolicy])); @@ -49,7 +49,7 @@ describe('AssetPublisherComponent', () => { it('should set policies when requesting policies', async function() { const { fixture } = await renderAssetPublisherComponent(); const { componentInstance } = fixture; - const dummyPolicy: Policy = { policyId: 'id-1', createdOn: 'testdate', validUntil: 'testdate' }; + const dummyPolicy: Policy = { policyId: 'id-1', createdOn: 'testdate', validUntil: 'testdate', permissions: [] }; const submittedSpy = spyOn(componentInstance.submitted, 'emit'); diff --git a/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.ts b/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.ts index b5ac0d0ee1..0d6f9ffbbb 100644 --- a/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.ts +++ b/frontend/src/app/modules/shared/components/asset-publisher/asset-publisher.component.ts @@ -1,9 +1,11 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormControl, Validators } from '@angular/forms'; +import { PoliciesAssembler } from '@page/admin/presentation/policy-management/policies/policy.assembler'; import { ImportState, Part } from '@page/parts/model/parts.model'; import { Policy } from '@page/policies/model/policy.model'; import { PolicyService } from '@shared/service/policy.service'; import { Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; @Component({ selector: 'app-asset-publisher', @@ -55,7 +57,9 @@ export class AssetPublisherComponent { } private getPolicies() { - this.policiesSubscription = this.policyService.getPolicies().subscribe(data => { + this.policiesSubscription = this.policyService.getPolicies().pipe(map(response => { + return PoliciesAssembler.mapToPolicyEntryList(response).map(entry => entry.payload.policy).map(policy => PoliciesAssembler.assemblePolicy(policy)); + })).subscribe(data => { this.policiesList = data; }) } diff --git a/frontend/src/app/modules/shared/components/multi-select-autocomplete/table-type.model.ts b/frontend/src/app/modules/shared/components/multi-select-autocomplete/table-type.model.ts index 27a8a5a6d7..c2c495de62 100644 --- a/frontend/src/app/modules/shared/components/multi-select-autocomplete/table-type.model.ts +++ b/frontend/src/app/modules/shared/components/multi-select-autocomplete/table-type.model.ts @@ -22,7 +22,8 @@ export enum TableType { AS_PLANNED_OWN = 'AS_PLANNED_OWN', RECEIVED_NOTIFICATION = 'RECEIVED_NOTIFICATION', SENT_NOTIFICATION = 'SENT_NOTIFICATION', - CONTRACTS='CONTRACTS' + CONTRACTS = 'CONTRACTS', + POLICIES = 'POLICIES' } export enum NotificationChannel { diff --git a/frontend/src/app/modules/shared/components/parts-table/policies-configuration.model.ts b/frontend/src/app/modules/shared/components/parts-table/policies-configuration.model.ts new file mode 100644 index 0000000000..5dc0412b82 --- /dev/null +++ b/frontend/src/app/modules/shared/components/parts-table/policies-configuration.model.ts @@ -0,0 +1,21 @@ +import { TableFilterConfiguration } from '@shared/components/parts-table/parts-config.model'; + +export class PoliciesConfigurationModel extends TableFilterConfiguration { + constructor() { + const sortableColumns = { + select: false, + bpn: false, + policyName: false, + policyId: false, + accessType: false, + createdOn: false, + validUntil: false, + constraints: false, + menu: false, + }; + + const dateFields = [ 'createdOn', 'validUntil' ]; + const singleSearchFields = []; + super(sortableColumns, dateFields, singleSearchFields, true); + } +} diff --git a/frontend/src/app/modules/shared/components/table/table.component.html b/frontend/src/app/modules/shared/components/table/table.component.html index 841eea23b6..35b7b88f41 100644 --- a/frontend/src/app/modules/shared/components/table/table.component.html +++ b/frontend/src/app/modules/shared/components/table/table.component.html @@ -21,14 +21,17 @@
-
{{ tableHeader | i18n }} +
{{ tableHeader | i18n }} settings
-
+
+
+ + +
+ +
+
+ + +
+ [ngClass]="{'rows-dashboard': labelId?.startsWith('dashboard'), 'rows-policy': labelId?.startsWith('policies')} ">

{{ tableHeader | i18n }}

-
+

{{ selectedPartsInfoLabel | i18n : {count: selection?.selected?.length || 0} }}

- @@ -367,11 +405,14 @@

{{ 'table.noResultFound' | i18n }}

- + + {{ (element[column] | date:'yyyy-MM-dd HH:mm') }} + diff --git a/frontend/src/app/modules/shared/components/table/table.component.scss b/frontend/src/app/modules/shared/components/table/table.component.scss index 7579ffee22..47d3977286 100644 --- a/frontend/src/app/modules/shared/components/table/table.component.scss +++ b/frontend/src/app/modules/shared/components/table/table.component.scss @@ -255,3 +255,14 @@ tr.error { visibility: hidden; } +.policy-table-header { + font-size: 24px; + font-family: Catena-X SemiBold, sans-serif; + text-transform: uppercase; + color: rgb(17, 17, 17); +} + +.rows-policy { + height: 63vh !important; +} + diff --git a/frontend/src/app/modules/shared/components/table/table.component.ts b/frontend/src/app/modules/shared/components/table/table.component.ts index 5395d40b7b..bbfa45f682 100644 --- a/frontend/src/app/modules/shared/components/table/table.component.ts +++ b/frontend/src/app/modules/shared/components/table/table.component.ts @@ -43,6 +43,7 @@ import { TableHeaderSort, } from '@shared/components/table/table.model'; import { ToastService } from '@shared/components/toasts/toast.service'; +import { isDateFilter } from '@shared/helper/filter-helper'; import { addSelectedValues, clearAllRows, clearCurrentRows, removeSelectedValues } from '@shared/helper/table-helper'; import { NotificationStatus } from '@shared/model/notification.model'; import { FlattenObjectPipe } from '@shared/pipes/flatten-object.pipe'; @@ -60,6 +61,7 @@ export class TableComponent { @ViewChild('tableElement', { read: ElementRef }) tableElementRef: ElementRef; @Input() additionalTableHeader = false; @Input() tableHeaderMenuEnabled = false; + @Input() basicTableHeaderMenuEnabled = false; @Input() set tableConfig(tableConfig: TableConfig) { @@ -146,6 +148,7 @@ export class TableComponent { @Output() multiSelect = new EventEmitter(); @Output() clickSelectAction = new EventEmitter(); @Output() filterActivated = new EventEmitter(); + @Output() deletionClicked = new EventEmitter(); @Input() public autocompleteEnabled = false; @Input() tableSettingsEnabled: boolean = false; @@ -380,6 +383,10 @@ export class TableComponent { this.router.navigate([ 'inbox/create' ]); } + navigateToCreationPath() { + this.router.navigate([ this.router.url, 'create' ]); + } + private menuActionsWithAddedDefaultActions(menuActionsConfig: MenuActionConfig[] = []): MenuActionConfig[] { const viewDetailsMenuAction: MenuActionConfig = { label: 'actions.viewDetails', @@ -400,6 +407,13 @@ export class TableComponent { return [ ...defaultActionsToAdd, ...menuActionsConfig ]; }; - protected readonly MainAspectType = MainAspectType; + handleItemDeletion() { + this.deletionClicked.emit(); + } + public isDateElement(key: string) { + return isDateFilter(key); + } + + protected readonly MainAspectType = MainAspectType; } diff --git a/frontend/src/app/modules/shared/helper/filter-helper.ts b/frontend/src/app/modules/shared/helper/filter-helper.ts index 41b64b1b85..76956403f2 100644 --- a/frontend/src/app/modules/shared/helper/filter-helper.ts +++ b/frontend/src/app/modules/shared/helper/filter-helper.ts @@ -26,10 +26,10 @@ import { import { NotificationDeeplinkFilter } from '@shared/model/notification.model'; -export const DATE_FILTER_KEYS = [ 'manufacturingDate', 'functionValidFrom', 'functionValidUntil', 'validityPeriodFrom', 'validityPeriodTo', 'createdDate', 'targetDate', 'creationDate', 'endDate' ]; +export const DATE_FILTER_KEYS = [ 'manufacturingDate', 'functionValidFrom', 'functionValidUntil', 'validityPeriodFrom', 'validityPeriodTo', 'createdDate', 'targetDate', 'creationDate', 'endDate', 'createdOn', 'validUntil' ]; // TODO: Refactor function -export function enrichFilterAndGetUpdatedParams(filter: AssetAsBuiltFilter, params: HttpParams, filterOperator: string): HttpParams { +export function enrichFilterAndGetUpdatedParams(filter: AssetAsBuiltFilter | any, params: HttpParams, filterOperator: string): HttpParams { for (const key in filter) { let operator: string; diff --git a/frontend/src/app/modules/shared/helper/table-helper.ts b/frontend/src/app/modules/shared/helper/table-helper.ts index 7600edb4d8..8134a2e9e2 100644 --- a/frontend/src/app/modules/shared/helper/table-helper.ts +++ b/frontend/src/app/modules/shared/helper/table-helper.ts @@ -36,9 +36,9 @@ export function clearAllRows(selection: any, multiSelect: any): void { } export function clearCurrentRows(selection: any, dataSourceData: unknown[], multiSelect: any): void { - this.removeSelectedValues(selection, dataSourceData); + removeSelectedValues(selection, dataSourceData); - multiSelect.emit(this.selection.selected); + multiSelect.emit([]); } export function setMultiSorting( event: KeyboardEvent): boolean { diff --git a/frontend/src/app/modules/shared/model/view.model.ts b/frontend/src/app/modules/shared/model/view.model.ts index 8ba9bf0a91..9674fd67ba 100644 --- a/frontend/src/app/modules/shared/model/view.model.ts +++ b/frontend/src/app/modules/shared/model/view.model.ts @@ -40,3 +40,9 @@ export class View implements OptionalViewData, OptionalViewError, Optional loader?: boolean; error?: Error; } + +export enum ViewMode { + VIEW = 'view', + EDIT = 'edit', + CREATE = 'create' +} diff --git a/frontend/src/app/modules/shared/pipes/error-message.pipe.ts b/frontend/src/app/modules/shared/pipes/error-message.pipe.ts index 041c627c35..a84659e966 100644 --- a/frontend/src/app/modules/shared/pipes/error-message.pipe.ts +++ b/frontend/src/app/modules/shared/pipes/error-message.pipe.ts @@ -81,7 +81,9 @@ export class ErrorMessagePipe implements PipeTransform { [ 'bpn', _ => getErrorMapping('bpn') ], [ 'invalidBpn', _ => getErrorMapping('invalidBpn' ) ], [ 'email', _ => getErrorMapping('email') ], + [ 'pastDate', _ => getErrorMapping('pastDate') ], [ 'generic', _ => getErrorMapping('generic') ], + [ 'minimumOneConstraint', _ => getErrorMapping('minimumOneConstraint') ], ]); const keys = Object.keys(errors); diff --git a/frontend/src/app/modules/shared/service/policy.service.spec.ts b/frontend/src/app/modules/shared/service/policy.service.spec.ts index c91603f210..7b8c3f27d3 100644 --- a/frontend/src/app/modules/shared/service/policy.service.spec.ts +++ b/frontend/src/app/modules/shared/service/policy.service.spec.ts @@ -2,30 +2,241 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { TestBed } from '@angular/core/testing'; import { ApiService } from '@core/api/api.service'; import { AuthService } from '@core/auth/auth.service'; +import { environment } from '@env'; +import { OperatorType, Policy, PolicyAction, PolicyEntry, PolicyResponseMap } from '@page/policies/model/policy.model'; import { KeycloakService } from 'keycloak-angular'; - +import { MockPolicyResponseMap } from '../../../mocks/services/policy-mock/policy.model'; import { PolicyService } from './policy.service'; -describe('AssetPublisherService', () => { +describe('PolicyService', () => { let service: PolicyService; - let httpTestingController: HttpTestingController; + let httpMock: HttpTestingController; + let apiUrl: string; let authService: AuthService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [PolicyService, ApiService, KeycloakService, AuthService], + imports: [ HttpClientTestingModule ], + providers: [ PolicyService, ApiService, KeycloakService, AuthService ], }); service = TestBed.inject(PolicyService); - httpTestingController = TestBed.inject(HttpTestingController); + httpMock = TestBed.inject(HttpTestingController); + apiUrl = environment.apiUrl; authService = TestBed.inject(AuthService); }); afterEach(() => { - httpTestingController.verify(); + httpMock.verify(); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should get policies', () => { + spyOn(authService, 'getBearerToken').and.returnValue('your_mocked_token'); + + const mockResponse: PolicyResponseMap = MockPolicyResponseMap; + + service.getPolicies().subscribe(response => { + expect(response).toEqual(Object(mockResponse)); + }); + + const req = httpMock.expectOne(`${ apiUrl }/policies`); + expect(req.request.method).toEqual('GET'); + req.flush(mockResponse); + }); + + it('should get policy by id', () => { + spyOn(authService, 'getBearerToken').and.returnValue('your_mocked_token'); + + const policyId = '123'; + const mockPolicy: Policy = { + policyId: 'policy123', + createdOn: '2024-01-01T00:00:00Z', + validUntil: '2024-12-31T23:59:59Z', + permissions: [ + { + action: 'use' as PolicyAction, + constraint: { + and: [ + { + leftOperand: 'left1', + operator: { '@id': OperatorType.EQ }, + operatorTypeResponse: OperatorType.EQ, + 'odrl:rightOperand': 'right1', + }, + ], + or: [ + { + leftOperand: 'left2', + operator: { '@id': OperatorType.NEQ }, + operatorTypeResponse: OperatorType.NEQ, + 'odrl:rightOperand': 'right2', + }, + ], + }, + }, + ], + }; + const policyEntry: PolicyEntry = { + payload: { + '@context': { + odrl: 'test', + }, + '@id': 'entry123', + policy: mockPolicy, + }, + validUntil: '2024-01-01T00:00:00Z', + }; + + service.getPolicyById(policyId).subscribe(response => { + expect(response).toEqual(Object(mockPolicy)); + }); + + const req = httpMock.expectOne(`${ apiUrl }/policies/${ policyId }`); + expect(req.request.method).toEqual('GET'); + req.flush(mockPolicy); + }); + + it('should publish assets', () => { + spyOn(authService, 'getBearerToken').and.returnValue('your_mocked_token'); + + const assetIds = [ 'asset1', 'asset2' ]; + const policyId = '123'; + const mockResponse = {}; + + service.publishAssets(assetIds, policyId).subscribe(response => { + expect(response).toEqual(Object(mockResponse)); + }); + + const req = httpMock.expectOne(`${ apiUrl }/assets/publish`); + expect(req.request.method).toEqual('POST'); + const testObject = '{\"assetIds\":[\"asset1\",\"asset2\"],\"policyId\":\"123\"}'; + expect(JSON.parse(JSON.stringify(req.request.body))).toEqual(testObject); + req.flush(mockResponse); + }); + + it('should delete policy', () => { + spyOn(authService, 'getBearerToken').and.returnValue('your_mocked_token'); + + const policyId = '123'; + const mockResponse = {}; + + service.deletePolicy(policyId).subscribe(response => { + expect(response).toEqual(Object(mockResponse)); + }); + + const req = httpMock.expectOne(`${ apiUrl }/policies/${ policyId }`); + expect(req.request.method).toEqual('DELETE'); + req.flush(mockResponse); + }); + + it('should create policy', () => { + spyOn(authService, 'getBearerToken').and.returnValue('your_mocked_token'); + + const policyEntry: PolicyEntry = { + payload: { + '@context': { + odrl: 'test', + }, + '@id': 'entry123', + policy: { + policyId: 'policy123', + createdOn: '2024-01-01T00:00:00Z', + validUntil: '2024-12-31T23:59:59Z', + permissions: [ + { + action: 'use' as PolicyAction, + constraint: { + and: [ + { + leftOperand: 'left1', + operator: { '@id': OperatorType.EQ }, + operatorTypeResponse: OperatorType.EQ, + 'odrl:rightOperand': 'right1', + }, + ], + or: [ + { + leftOperand: 'left2', + operator: { '@id': OperatorType.NEQ }, + operatorTypeResponse: OperatorType.NEQ, + 'odrl:rightOperand': 'right2', + }, + ], + }, + }, + ], + }, + }, + validUntil: '2024-01-01T00:00:00Z', + }; + const mockResponse = {}; + + service.createPolicy(policyEntry).subscribe(response => { + expect(response).toEqual(Object(mockResponse)); + }); + + const req = httpMock.expectOne(`${ apiUrl }/policies`); + expect(req.request.method).toEqual('POST'); + expect(req.request.body).toEqual(JSON.stringify(policyEntry)); + req.flush(mockResponse); + }); + + it('should update policy', () => { + spyOn(authService, 'getBearerToken').and.returnValue('your_mocked_token'); + + const policyEntry: PolicyEntry = { + payload: { + '@context': { + odrl: 'test', + }, + '@id': 'entry123', + policy: { + policyId: 'policy123', + createdOn: '2024-01-01T00:00:00Z', + validUntil: '2024-12-31T23:59:59Z', + permissions: [ + { + action: 'use' as PolicyAction, + constraint: { + and: [ + { + leftOperand: 'left1', + operator: { '@id': OperatorType.EQ }, + operatorTypeResponse: OperatorType.EQ, + 'odrl:rightOperand': 'right1', + }, + ], + or: [ + { + leftOperand: 'left2', + operator: { '@id': OperatorType.NEQ }, + operatorTypeResponse: OperatorType.NEQ, + 'odrl:rightOperand': 'right2', + }, + ], + }, + }, + ], + }, + }, + validUntil: '2024-01-01T00:00:00Z', + }; + const body = { + policyIds: [ policyEntry.payload.policy.policyId ], + validUntil: policyEntry.validUntil, + }; + const mockResponse = {}; + + service.updatePolicy(policyEntry).subscribe(response => { + expect(response).toEqual(Object(mockResponse)); + }); + + const req = httpMock.expectOne(`${ apiUrl }/policies`); + expect(req.request.method).toEqual('PUT'); + expect(req.request.body).toEqual(JSON.stringify(body)); + req.flush(mockResponse); + }); }); diff --git a/frontend/src/app/modules/shared/service/policy.service.ts b/frontend/src/app/modules/shared/service/policy.service.ts index 2154cb5f92..ad0e6dfe5b 100644 --- a/frontend/src/app/modules/shared/service/policy.service.ts +++ b/frontend/src/app/modules/shared/service/policy.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { ApiService } from '@core/api/api.service'; import { environment } from '@env'; -import { Policy } from '@page/policies/model/policy.model'; +import { Policy, PolicyEntry, PolicyResponseMap } from '@page/policies/model/policy.model'; import { Observable } from 'rxjs'; @Injectable({ @@ -11,12 +11,35 @@ export class PolicyService { private readonly url = environment.apiUrl; constructor(private readonly apiService: ApiService) {} - getPolicies(): Observable { - return this.apiService - .getBy(`${this.url}/policies`); + getPolicies(): Observable { + return this.apiService.get(`${ this.url }/policies`); + } + + getPolicyById(policyId: string): Observable { + return this.apiService.getBy(`${ this.url }/policies/${ policyId }`); } publishAssets(assetIds: string[],policyId: string) { return this.apiService.post(`${this.url}/assets/publish`, {assetIds, policyId}); } + + deletePolicy(policyId: string) { + return this.apiService.delete(`${ this.url }/policies/` + policyId); + } + + createPolicy(policyEntry: PolicyEntry): Observable { + return this.apiService.post(`${ this.url }/policies`, policyEntry); + } + + updatePolicy(policyEntry: PolicyEntry) { + policyEntry.policyIds = [ policyEntry.payload.policy.policyId ]; + + const body = { + policyIds: [ policyEntry.payload.policy.policyId ], + validUntil: policyEntry.validUntil, + businessPartnerNumbers: policyEntry.businessPartnerNumber, + }; + + return this.apiService.put(`${ this.url }/policies`, body); + } } diff --git a/frontend/src/assets/locales/de/common.json b/frontend/src/assets/locales/de/common.json index 4916cb9200..3392a0067e 100644 --- a/frontend/src/assets/locales/de/common.json +++ b/frontend/src/assets/locales/de/common.json @@ -12,6 +12,7 @@ "adminRegistry": "Registry-Abfragen", "adminBpn": "BPN - EDC Konfiguration", "adminImport": "Datenbereitstellung", + "adminPolicies" : "Richtlinienverwaltung", "partMismatch": "Die ausgewählten Teile müssen vom selben Eigentümer sein, um ein Qualitätsthema zu erstellen (Eigen, Lieferant)", "unauthorized": "Die Funktion ist aufgrund einer fehlenden Rolle deaktiviert. Bitten Sie Ihren Administrator, die erforderliche Rolle für die Funktion bereitzustellen.", "notAllowedForAsPlanned": "Diese Funktion ist für Produkte im Lebenszyklus \"AsPlanned\" nicht verfügbar.", @@ -66,7 +67,9 @@ "goBack": "Zurück", "publishAssets": "Produkte veröffentlichen", "maximizeTable": "Volle Breite", - "userSettings": "Tabellen Einstellung" + "userSettings" : "Tabellen Einstellung", + "uploadFile" : "Richtlinien JSON-Datei hochladen", + "downloadFile" : "Vorlage als Datei herunterladen" }, "publisher": { "selectedAssets": "Ausgewählte Produkte", @@ -116,10 +119,13 @@ "viewDetails" : "Details anzeigen", "more" : "Mehr Aktionen", "editNotification" : "Qualitätsthemen bearbeiten", - "selectAtLeastOne" : "Aktionen erfordern mindestens eine Selektion in der Tabelle", + "selectAtLeastOne" : "Erfordert mindestens eine Selektion in der Tabelle", + "selectOnlyOne" : "Aktion erfordert eine einzige Selektion in der Tabelle", "unauthorized" : "Die Funktion ist aufgrund einer fehlenden Rolle deaktiviert. Bitten Sie Ihren Administrator, die erforderliche Rolle für die Funktion bereitzustellen.", "noFunctionality" : "Funktionalität wurde noch nicht implementiert.", "onlyNotificationInStatusCreatedAllowed" : "Aktion erfordert eine Selektion an Qualitätsthemen im Status \"Erstellt\".", + "createPolicy" : "Richtlinie erstellen", + "deletePolicy" : "Selektierte Richtlinien löschen", "column": { "id": "ID", "idShort": "Kurz-ID", @@ -162,8 +168,14 @@ "importNote": "Importbemerkung", "importDate": "Importdatum", "type": "Typ", - "title": "Titel" - + "title" : "Titel", + "policyId" : "Policy ID", + "validUntil" : "Gültig bis", + "createdOn" : "Gültig von", + "policyName" : "Richtlinienname", + "bpn" : "BPN Selektion", + "constraints" : "Bedingungen", + "accessType" : "Zugriffsart" } }, "dataLoading": { @@ -207,12 +219,14 @@ "maxLength": "Bitte geben Sie maximal {{maxLength}} Zeichen ein. Momentan: {{current}}", "pattern": "Bitte geben Sie die Daten in folgendem Format ein: {{- pattern}}.", "url": "Bitte geben Sie eine valide URL ein.", - "bpn": "Bitte geben Sie eine valide BPN ein. BPN {{applicationBpn}} ist nicht erlaubt.", + "bpn" : "Bitte geben Sie eine valide BPN ein.", "maxDate": "Bitte wählen Sie ein Datum vor dem {{- maxDate}}.", "minDate": "Bitte wählen Sie ein Datum nach dem {{- minDate}}.", "currentDate": "Bitte wählen Sie ein Datum nach dem {{- currentDate}}.", "generic": "Die Eingabe ist ungültig.", - "invalidBpn": "Die eigene BPN darf nicht verwendet werden." + "invalidBpn" : "Die eigene BPN darf nicht verwendet werden.", + "pastDate" : "Bitte wählen Sie ein Datum in Zukunft aus.", + "minimumOneConstraint" : "Bitte fügen Sie mindestens eine valide Richtlinie hinzu." }, "requestAlert": { "headline": "Qualitätswarnung erstellen", diff --git a/frontend/src/assets/locales/de/page.admin.json b/frontend/src/assets/locales/de/page.admin.json index 6656742029..7616c7517c 100644 --- a/frontend/src/assets/locales/de/page.admin.json +++ b/frontend/src/assets/locales/de/page.admin.json @@ -52,9 +52,50 @@ "selectAtleastOne": "Selektieren Sie mindestens einen Vertrag um exportieren zu können.", "exportAsCSV": ".CSV-Datei Export", "overview": "Übersicht", - "policyDetail": "Richtlinien details", + "policyDetail" : "Vereinbarungsdetails", "jsonViewer": "Json Ansicht", "jsonTreeViewer": "Json-Baumansicht" + }, + "policyManagement" : { + "policyManagement" : "Richtlinienverwaltung", + "deleteSuccess" : "Selektierte Richtlinien wurden erfolgreich gelöscht.", + "deleteError" : "Fehler bei der Löschung der selektierten Richtlinien.", + "confirm" : "Bestätigen", + "policyDeletion" : "Löschen bestätigen", + "deletionText" : "Möchten sie die selektierten Richtlinien löschen?", + "policy" : "Richtlinie", + "create" : "erstellen", + "edit" : "bearbeiten", + "view" : "details", + "selectFileHint" : "Name der selektierten Vorlage", + "applyChange" : "Inhalt der hochgeladenen Datei anwenden und existierende Daten überschreiben", + "chooseFile" : "Selektieren Sie eine Vorlage um Änderungen anzuwenden", + "apply" : "Anwenden", + "policyName" : "Richtlinienname", + "validUntil" : "Gültig bis", + "bpnls" : "BPNs", + "bpn" : "BPN", + "policyConstraints" : "Richtlinientyp", + "accessType" : "Zugriffstyp", + "constraints" : "Regeln", + "logic" : "Logik", + "logicTypeHint" : "Bitte beachten Sie, dass die Reihenfolge der Regeln relevant ist", + "constraintLogicType" : "Logik der Regel", + "leftOperand" : "Linker Operand", + "operator" : "Operator", + "rightOperand" : "Rechter Operand", + "rightOperandHint" : "Bitte geben Sie kommagetrennten Text für die Operatoren IN, ISONEOF, ISALLOF, ISNONEOF an", + "moveDownward" : "Nach unten verschieben", + "moveUpward" : "Nach oben verschieben", + "successMessage" : "Richtlinie wurde erfolgreich gespeichert", + "changeSuccessMessage" : "JSON-Datei erfolgreich angewandt", + "errorMessage" : "Fehler beim Versuch die Richtlinie abzuspeichern", + "templateErrorMessage" : "Die selektierte Vorlagendatei ist nicht konform für Richtlinien. Bitte wählen Sie eine valide .JSON Vorlagendatei aus.", + "invalidForm" : "Bitte füllen Sie die Eingabefelder korrekt aus", + "bpnsHint" : "Liste von BPNs separiert durch ein Kommazeichen", + "bpnHint" : "", + "savePolicy" : "Richtlinie speichern", + "addConstraint" : "Regel hinzufügen" } } } diff --git a/frontend/src/assets/locales/en/common.json b/frontend/src/assets/locales/en/common.json index de01b0d767..3538e19c9f 100644 --- a/frontend/src/assets/locales/en/common.json +++ b/frontend/src/assets/locales/en/common.json @@ -11,6 +11,7 @@ "adminRegistry": "Registry lookups", "adminBpn": "BPN - EDC configuration", "adminImport": "Data provisioning", + "adminPolicies" : "Policy management", "partMismatch": "Selected parts must have same owner to create quality topic (Own, Supplier)", "unauthorized": "Functionality is disabled because of missing role. Ask your administrator to provide the required role for the functionality.", "notAllowedForAsPlanned": "This function is not available for Parts in the Lifecycle \"AsPlanned\".", @@ -64,7 +65,9 @@ "goBack": "Back", "publishAssets": "Publish assets", "maximizeTable": "Full width", - "userSettings": "Table settings" + "userSettings" : "Table settings", + "uploadFile" : "Upload policy JSON file", + "downloadFile" : "Download template as file" }, "publisher": { "selectedAssets": "Assets selected", @@ -113,10 +116,13 @@ "viewDetails" : "View details", "more" : "More actions", "editNotification" : "Edit quality topics", - "selectAtLeastOne" : "Actions require atleast one selection in the table", + "selectAtLeastOne" : "Requires at least one selection in the policies table", + "selectOnlyOne" : "Action requires only one selection from the table", "unauthorized" : "Functionality is disabled because of missing role. Ask your administrator to provide the required role for the functionality.", "noFunctionality" : "Functionality is not implemented yet", "onlyNotificationInStatusCreatedAllowed" : "Action only allowed with a selection of notifications in status \"Queued\".", + "createPolicy" : "Create policy", + "deletePolicy" : "Delete selected policies", "column": { "id": "ID", "idShort": "ID Short", @@ -158,7 +164,14 @@ "importNote": "Import Note", "importDate": "Import Date", "type": "Type", - "title": "Title" + "title" : "Title", + "policyId" : "Policy ID", + "validUntil" : "Valid until", + "createdOn" : "Valid from", + "policyName" : "Policy name", + "bpn" : "BPN selection", + "constraints" : "Constraints", + "accessType" : "Access type" } }, "dataLoading": { @@ -207,7 +220,9 @@ "minDate": "Please select a date that is after {{- minDate}}.", "currentDate": "Please select a date that is after {{- currentDate}}.", "generic": "Please enter valid data.", - "invalidBpn": "Must not be own BPN." + "invalidBpn" : "Must not be own BPN.", + "pastDate" : "Please select a date in the future.", + "minimumOneConstraint" : "Please add atleast one valid constraint to the policy" }, "unitTest": { "test01": "This is for unit tests purposes.", diff --git a/frontend/src/assets/locales/en/page.admin.json b/frontend/src/assets/locales/en/page.admin.json index ff2b82f53a..adb2c3693f 100644 --- a/frontend/src/assets/locales/en/page.admin.json +++ b/frontend/src/assets/locales/en/page.admin.json @@ -52,9 +52,51 @@ "selectAtleastOne": "Select atleast one contract to use the export functionality.", "exportAsCSV": ".CSV-File Export", "overview": "Overview", - "policyDetail": "Policy detail", + "policyDetail" : "Agreement details", "jsonViewer": "Json Viewer", "jsonTreeViewer": "Json Tree Viewer" + }, + "policyManagement" : { + "policyManagement" : "Policy management", + "deleteSuccess" : "Successfully deleted the selected policies.", + "deleteError" : "Error while deleting the selected policies.", + "confirm" : "Confirm", + "policyDeletion" : "Confirm deletion", + "deletionText" : "Do you want to delete the selected policies?", + "policy" : "Policy", + "create" : "creation", + "edit" : "edit", + "view" : "details", + "selectFileHint" : "Selected template filename", + "applyChange" : "Apply content of file overwriting existing data", + "chooseFile" : "Select a template file to be able to apply it", + "apply" : "Apply", + "policyName" : "Policy name", + "validUntil" : "Valid until", + "bpnls" : "BPNs", + "bpn" : "BPN", + "policyConstraints" : "Policy type", + "accessType" : "Access type", + "constraints" : "Constraints", + "logic" : "Logic", + "logicTypeHint" : "Please note that the order of the constraints is relevant", + "constraintLogicType" : "Constraint logic", + "leftOperand" : "Left operand", + "operator" : "Operator", + "rightOperand" : "Right operand", + "rightOperandHint" : "Please provide comma separated text for the operators IN, ISONEOF, ISALLOF, ISNONEOF", + "moveDownward" : "Move downward", + "moveUpward" : "Move upward", + "successMessage" : "Successfully saved policy", + "changeSuccessMessage" : "JSON file successfully applied", + "errorMessage" : "Error while trying to save policy", + "templateErrorMessage" : "The selected template file is not compliant. Please provide a valid .JSON template file.", + "invalidForm" : "Please make sure to submit a valid form", + "bpnsHint" : "Please provide a list of BPNs separated by comma", + "bpnHint" : "Please provide a BPN", + "savePolicy" : "Save policy", + "addConstraint" : "Add constraint" + } } } diff --git a/frontend/src/theme/base.scss b/frontend/src/theme/base.scss index 296abff8d0..4f29d58125 100644 --- a/frontend/src/theme/base.scss +++ b/frontend/src/theme/base.scss @@ -205,7 +205,7 @@ app-parts{ } } -app-notifications-tab { +app-notifications-tab, app-policies { .table-wrapper { background-color: white; @media screen and (max-height: 1049px) { @@ -268,7 +268,7 @@ app-multiselect { bottom: 0; } -app-parts, app-notifications-tab { +app-parts, app-notifications-tab, app-policies { .mat-mdc-table .mdc-data-table__row:last-child { border-bottom: 1px solid lightgrey;