diff --git a/cypress/e2e/datasets/datasets-detail-dynamic-tile-authorization.cy.js b/cypress/e2e/datasets/datasets-detail-dynamic-tile-authorization.cy.js new file mode 100644 index 000000000..21490187e --- /dev/null +++ b/cypress/e2e/datasets/datasets-detail-dynamic-tile-authorization.cy.js @@ -0,0 +1,147 @@ +import { testConfig } from "../../fixtures/testData"; +import { mergeConfig } from "../../support/utils"; + +describe("Dataset Detail Dynamic Tile Authorization", () => { + const authTestConfig = { + datasetDetailComponent: { + enableCustomizedComponent: true, + customization: [ + { + type: "regular", + label: "Admin Only Section", + order: 1, + row: 1, + col: 10, + authorization: ["admin"], + fields: [ + { element: "text", source: "datasetName", order: 0 }, + { element: "copy", source: "description", order: 1 }, + ], + }, + { + type: "regular", + label: "Public Section", + order: 2, + row: 1, + col: 10, + authorization: ["#all"], + fields: [ + { element: "text", source: "ownerEmail", order: 0 }, + { element: "tag", source: "keywords", order: 1 }, + ], + }, + { + type: "regular", + label: "Archive Manager Section", + order: 3, + row: 1, + col: 10, + authorization: ["archivemanager"], + fields: [ + { element: "text", source: "type", order: 0 }, + { element: "date", source: "creationTime", order: 1 }, + ], + }, + ], + }, + }; + + const adminUsername = Cypress.env("username"); + const adminPassword = Cypress.env("password"); + const archiveManagerUsername = Cypress.env("secondaryUsername"); + const archiveManagerPassword = Cypress.env("secondaryPassword"); + const guestUsername = Cypress.env("guestUsername"); + const guestPassword = Cypress.env("guestPassword"); + + beforeEach(() => { + cy.readFile("CI/e2e/frontend.config.e2e.json").then((baseConfig) => { + const mergedConfig = mergeConfig(baseConfig, authTestConfig); + cy.intercept("GET", "**/admin/config", mergedConfig).as( + "getFrontendConfig", + ); + }); + + cy.login(adminUsername, adminPassword); + cy.createDataset({ type: "raw" }); + cy.visit("/datasets"); + cy.wait("@getFrontendConfig"); + }); + + after(() => { + cy.removeDatasets(); + }); + + it("should show all tiles to admin user", () => { + cy.finishedLoading(); + + cy.get('[data-cy="text-search"]').clear().type("Cypress"); + cy.get('[data-cy="search-button"]').click(); + + cy.isLoading(); + + cy.get("mat-row").contains("Cypress Dataset").click(); + + cy.isLoading(); + + // Admin user should see all sections + cy.get('[data-cy="section-label"]').should("contain", "Admin Only Section"); + cy.get('[data-cy="section-label"]').should("contain", "Public Section"); + // But NOT archiv3e manager only section + cy.get('[data-cy="section-label"]:contains("Archive Manager Section")').should( + "not.exist", + ); + + }); + + it("should hide admin-only tile from archiveManager user", () => { + cy.login(archiveManagerUsername, archiveManagerPassword); + cy.visit("/datasets"); + cy.wait("@getFrontendConfig"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"]').clear().type("Cypress"); + cy.get('[data-cy="search-button"]').click(); + + cy.isLoading(); + + cy.get("mat-row").contains("Cypress Dataset").click(); + + cy.isLoading(); + + // Archive Manager should see public and archiveManager sections + cy.get('[data-cy="section-label"]').should("contain", "Public Section"); + cy.get('[data-cy="section-label"]').should("contain", "Archive Manager Section"); + // But NOT admin-only section + cy.get('[data-cy="section-label"]:contains("Admin Only Section")').should( + "not.exist", + ); + }); + + it("should only show public tile to guest user", () => { + cy.login(guestUsername, guestPassword); + cy.visit("/datasets"); + cy.wait("@getFrontendConfig"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"]').clear().type("Cypress"); + cy.get('[data-cy="search-button"]').click(); + + cy.isLoading(); + + cy.get("mat-row").contains("Cypress Dataset").click(); + + cy.isLoading(); + + // Guest user (user1) should only see public section + cy.get('[data-cy="section-label"]').should("contain", "Public Section"); + // Should NOT see admin-only or archiveManager sections + cy.get('[data-cy="section-label"]:contains("Admin Only Section")').should( + "not.exist", + ); + cy.get('[data-cy="section-label"]:contains("Archive Manager Section")').should( + "not.exist", + ); + }); +}); diff --git a/src/app/admin/schema/frontend.config.jsonforms.json b/src/app/admin/schema/frontend.config.jsonforms.json index 938dbce2d..58e23e855 100644 --- a/src/app/admin/schema/frontend.config.jsonforms.json +++ b/src/app/admin/schema/frontend.config.jsonforms.json @@ -139,7 +139,6 @@ "url": { "type": "string" }, "target": { "type": "string" }, "enabled": { "type": "string" }, - "authorization": { "type": "array", "items": { "type": "string" } }, "inputs": { "type": "array", "items": { @@ -356,6 +355,10 @@ "type": "string", "enum": ["table", "json", "tree"] }, + "authorization": { + "type": "array", + "items": { "type": "string" } + }, "options": { "type": "object", @@ -660,10 +663,6 @@ "options": { "multi": true } }, { "type": "Control", "scope": "#/properties/filename" }, - { - "type": "Control", - "scope": "#/properties/authorization" - }, { "type": "Control", "scope": "#/properties/variables", @@ -765,7 +764,11 @@ "elements": [ { "type": "Control", "scope": "#/properties/label" }, { "type": "Control", "scope": "#/properties/type" }, - { "type": "Control", "scope": "#/properties/order" }, + { "type": "Control", "scope": "#/properties/order" }] + }, + { + "type": "HorizontalLayout", + "elements": [ { "type": "Control", "scope": "#/properties/row" }, { "type": "Control", "scope": "#/properties/col" }, { @@ -783,6 +786,15 @@ } ] }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/authorization" + } + ] + }, { "type": "VerticalLayout", "label": "Attachments Options", diff --git a/src/app/datasets/dataset-detail/dataset-detail-dynamic/_dataset-detail-dynamic-theme.scss b/src/app/datasets/dataset-detail/dataset-detail-dynamic/_dataset-detail-dynamic-theme.scss index 79bcefe86..15e8273f4 100644 --- a/src/app/datasets/dataset-detail/dataset-detail-dynamic/_dataset-detail-dynamic-theme.scss +++ b/src/app/datasets/dataset-detail/dataset-detail-dynamic/_dataset-detail-dynamic-theme.scss @@ -9,8 +9,12 @@ $header-2: map.get($color-config, "header-2"); $header-3: map.get($color-config, "header-3"); $header-4: map.get($color-config, "header-4"); + $header-5: map.get($color-config, "header-5"); $warn: map.get($color-config, "warn"); $warn-2: map.get($color-config, "warn-2"); + .dynamic-template-tile-header { + background-color: mat.m2-get-color-from-palette($header-5, "lighter"); + } .dynamic-template-general-header { background-color: mat.m2-get-color-from-palette($primary, "lighter"); } diff --git a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html index 6988b2617..a7452624f 100644 --- a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html +++ b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html @@ -28,7 +28,7 @@ *ngIf="dataset$ | async as dataset" > - +
@@ -160,7 +160,7 @@
@@ -218,7 +218,7 @@ >
@@ -285,7 +285,7 @@ >
diff --git a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts index a47e16738..b2297b682 100644 --- a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts +++ b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit, OnDestroy } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; -import { Store } from "@ngrx/store"; -import { Subscription } from "rxjs"; +import { createSelector, Store } from "@ngrx/store"; +import { Subscription, Observable } from "rxjs"; +import { map } from "rxjs/operators"; import { showMessageAction } from "state-management/actions/user.actions"; import { @@ -10,7 +11,10 @@ import { selectCurrentDataset, selectCurrentDatasetWithoutFileInfo, } from "state-management/selectors/datasets.selectors"; -import { selectIsLoading } from "state-management/selectors/user.selectors"; +import { + selectIsLoading, + selectProfile, +} from "state-management/selectors/user.selectors"; import { selectCurrentInstrument } from "state-management/selectors/instruments.selectors"; import { AppConfigService } from "app-config.service"; @@ -35,6 +39,12 @@ import { ActionItems, } from "shared/modules/configurable-actions/configurable-action.interfaces"; +// profile.selectors.ts +export const selectProfileAccessGroups = createSelector( + selectProfile, + (profile) => profile?.accessGroups || [], +); + /** * Component to show customizable details for a dataset, using the * form component @@ -50,7 +60,7 @@ import { export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { private subscriptions: Subscription[] = []; - datasetView: CustomizationItem[]; + datasetView$: Observable; form: FormGroup; cols = 10; gutterSize = 12; @@ -65,6 +75,8 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { loading$ = this.store.select(selectIsLoading); show = false; + userGroups$: Observable; + instrument: Instrument | undefined; dataset: OutputDatasetObsoleteDto | undefined; @@ -83,7 +95,9 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { private router: Router, private route: ActivatedRoute, private snackBar: MatSnackBar, - ) {} + ) { + this.userGroups$ = this.store.select(selectProfileAccessGroups); + } ngOnInit() { this.form = this.fb.group({}); @@ -97,7 +111,13 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { } }); - this.datasetView = sortedDatasetView; + this.datasetView$ = this.userGroups$.pipe( + map((userGroups) => + sortedDatasetView.filter((section) => + this.canViewBlock(section.authorization, userGroups), + ), + ), + ); this.subscriptions.push( this.store.select(selectCurrentInstrument).subscribe((instrument) => { @@ -120,6 +140,29 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { ); } + /** + * Checks if the current user can view a block based on authorization + * @param blockAuthorization - Optional array of group names from block.authorization + * @param userGroups - Array of groups the current user belongs to + * @returns true if user can view the block + */ + canViewBlock( + blockAuthorization: string[] | undefined, + userGroups: string[], + ): boolean { + const auth = blockAuthorization || ["#all"]; + + if (auth.includes("#all")) { + return true; + } + + if (auth.length === 0) { + return false; + } + + return auth.some((group) => userGroups.includes(group)); + } + onCopy(value: string) { navigator.clipboard.writeText(value).then( () => { diff --git a/src/app/state-management/models/index.ts b/src/app/state-management/models/index.ts index a12f9d0b2..9e7326c41 100644 --- a/src/app/state-management/models/index.ts +++ b/src/app/state-management/models/index.ts @@ -83,6 +83,7 @@ export interface CustomizationItem { source?: string; options?: AttachmentOptions; viewMode?: viewModeOptions; + authorization?: string[]; } export interface Field { diff --git a/src/app/theme.ts b/src/app/theme.ts index 6dcf13d3c..99167dbe1 100644 --- a/src/app/theme.ts +++ b/src/app/theme.ts @@ -50,6 +50,12 @@ export const light: Theme = { "--theme-header-4-lighter-contrast": "#000000", "--theme-header-4-darker": "#9b429b", "--theme-header-4-darker-contrast": "#ffffff", + "--theme-header-5-default": "#7f7f7f", + "--theme-header-5-default-contrast": "#ffffff", + "--theme-header-5-lighter": "#e5e5e5", + "--theme-header-5-lighter-contrast": "#000000", + "--theme-header-5-darker": "#4c4c4c", + "--theme-header-5-darker-contrast": "#ffffff", "--theme-hover-default": "#7f7f7f", "--theme-hover-default-contrast": "#000000", "--theme-hover-lighter": "#e5e5e5", diff --git a/src/assets/theme.json b/src/assets/theme.json index 401aac130..6056e689e 100644 --- a/src/assets/theme.json +++ b/src/assets/theme.json @@ -49,6 +49,12 @@ "--theme-header-4-lighter-contrast": "#000000", "--theme-header-4-darker": "#9b429b", "--theme-header-4-darker-contrast": "#ffffff", + "--theme-header-5-default": "#7f7f7f", + "--theme-header-5-default-contrast": "#ffffff", + "--theme-header-5-lighter": "#e5e5e5", + "--theme-header-5-lighter-contrast": "#000000", + "--theme-header-5-darker": "#4c4c4c", + "--theme-header-5-darker-contrast": "#ffffff", "--theme-hover-default": "#7f7f7f", "--theme-hover-default-contrast": "#000000", "--theme-hover-lighter": "#e5e5e5", diff --git a/src/styles.scss b/src/styles.scss index 59d65484a..e5348a712 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -152,6 +152,17 @@ $theme-header-4: ( ), ); +$theme-header-5: ( + default: var(--theme-header-5-default), + lighter: var(--theme-header-5-lighter), + darker: var(--theme-header-5-darker), + contrast: ( + default: var(--theme-header-5-default-contrast), + lighter: var(--theme-header-5-lighter-contrast), + darker: var(--theme-header-5-darker-contrast), + ), +); + $theme-hover: ( default: var(--theme-hover-default), lighter: var(--theme-hover-lighter), @@ -191,6 +202,12 @@ $header-4: mat.m2-define-palette( "lighter", "darker" ); +$header-5: mat.m2-define-palette( + $theme-header-5, + "default", + "lighter", + "darker" +); $hover: mat.m2-define-palette($theme-hover, "default", "lighter", "darker"); $basetheme: mat.m2-define-light-theme( @@ -211,8 +228,9 @@ $newcolor: map.merge( warn-2: $warn-2, header-1: $header-1, header-2: $header-2, - header-3: $header-4, + header-3: $header-3, header-4: $header-4, + header-5: $header-5, hover: $hover, ) ); @@ -476,4 +494,4 @@ display: inline-flex; .mat-mdc-form-field-icon-suffix { display: flex; align-items: center; -} \ No newline at end of file +}