Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
24 changes: 18 additions & 6 deletions src/app/admin/schema/frontend.config.jsonforms.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@
"url": { "type": "string" },
"target": { "type": "string" },
"enabled": { "type": "string" },
"authorization": { "type": "array", "items": { "type": "string" } },
"inputs": {
"type": "array",
"items": {
Expand Down Expand Up @@ -356,6 +355,10 @@
"type": "string",
"enum": ["table", "json", "tree"]
},
"authorization": {
"type": "array",
"items": { "type": "string" }
},

"options": {
"type": "object",
Expand Down Expand Up @@ -660,10 +663,6 @@
"options": { "multi": true }
},
{ "type": "Control", "scope": "#/properties/filename" },
{
"type": "Control",
"scope": "#/properties/authorization"
},
{
"type": "Control",
"scope": "#/properties/variables",
Expand Down Expand Up @@ -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" },
{
Expand All @@ -783,6 +786,15 @@
}
]
},
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/authorization"
}
]
},
{
"type": "VerticalLayout",
"label": "Attachments Options",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
*ngIf="dataset$ | async as dataset"
>
<!-- Iterate over each section in datasetView -->
<ng-container *ngFor="let section of datasetView">
<ng-container *ngFor="let section of datasetView$ | async">
<ng-container [ngSwitch]="section.type">
<!-- Regular Section -->
<div
Expand All @@ -40,7 +40,7 @@
<mat-card>
<!-- Section Header -->
<mat-card-header
class="dynamic-template-general-header sticky-header"
class="dynamic-template-tile-header sticky-header"
data-cy="section-label"
>
<div mat-card-avatar class="section-icon">
Expand Down Expand Up @@ -160,7 +160,7 @@
<ng-container *ngIf="emptyMetadataTable()">
<mat-card>
<mat-card-header
class="scientific-header sticky-header"
class="dynamic-template-tile-header sticky-header"
data-cy="section-label"
>
<div mat-card-avatar class="section-icon">
Expand Down Expand Up @@ -218,7 +218,7 @@
>
<mat-card>
<mat-card-header
class="dynamic-template-attachments-header sticky-header"
class="dynamic-template-tile-header sticky-header"
data-cy="section-label"
>
<div mat-card-avatar class="section-icon">
Expand Down Expand Up @@ -285,7 +285,7 @@
>
<mat-card>
<mat-card-header
class="dynamic-template-json-view-header sticky-header"
class="dynamic-template-tile-header sticky-header"
data-cy="section-label"
>
<div mat-card-avatar class="section-icon">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
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 {
selectCurrentAttachments,
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";
Expand All @@ -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
Expand All @@ -50,7 +60,7 @@ import {
export class DatasetDetailDynamicComponent implements OnInit, OnDestroy {
private subscriptions: Subscription[] = [];

datasetView: CustomizationItem[];
datasetView$: Observable<CustomizationItem[]>;
form: FormGroup;
cols = 10;
gutterSize = 12;
Expand All @@ -65,6 +75,8 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy {
loading$ = this.store.select(selectIsLoading);
show = false;

userGroups$: Observable<string[]>;

instrument: Instrument | undefined;
dataset: OutputDatasetObsoleteDto | undefined;

Expand All @@ -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);
}
Comment on lines 78 to +100

@Junjiequan Junjiequan Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be removed by adding userGroups$ = this.store.select(selectProfileAccessGroups); on line 78


ngOnInit() {
this.form = this.fb.group({});
Expand All @@ -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) => {
Expand All @@ -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;
}
Comment on lines +158 to +161

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this condition intentional? that authorization: [] blocks all users from accessing the widget? In the admin panel, if we add an authorization group and then delete it, the frontend config JSON ends up with authorization: [], and nobody can access the widget, until it is changed to authorization: [#all].

I'm not sure which behavior is more intuitive

    if (auth.length === 0 || auth.includes("#all")) {
      return true;
    }

or return false when authorization is an empty array

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this seems inconsistent, arising from the fact that an empty array is "trueish" in js ( as opposed to e.g python), casing line +153 to pass on the empty array. I would adjust the code either map empty to "#all" or if possible handle it in the config setting to set it to undefined/null if all elements are dropped


return auth.some((group) => userGroups.includes(group));
}

onCopy(value: string) {
navigator.clipboard.writeText(value).then(
() => {
Expand Down
1 change: 1 addition & 0 deletions src/app/state-management/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface CustomizationItem {
source?: string;
options?: AttachmentOptions;
viewMode?: viewModeOptions;
authorization?: string[];
}

export interface Field {
Expand Down
6 changes: 6 additions & 0 deletions src/app/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +53 to +58

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend needs to be updated accordingly

"--theme-hover-default": "#7f7f7f",
"--theme-hover-default-contrast": "#000000",
"--theme-hover-lighter": "#e5e5e5",
Expand Down
Loading
Loading