This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 905c17d93e feat(frontend): gate the file tree's delete and cover
controls on write access (#8352)
905c17d93e is described below
commit 905c17d93e0975eb501581aab3716f9e98a50b0d
Author: Tanishq Gandhi <[email protected]>
AuthorDate: Thu Sep 3 21:52:23 2026 +0000
feat(frontend): gate the file tree's delete and cover controls on write
access (#8352)
### What changes were proposed in this PR?
A collaborator with READ access saw write controls in the file tree.
Nothing insecure happened — the backend rejects the calls — but the
buttons should not be offered:
- On a dataset shared READ-only, every file row showed a delete (trash)
icon: the page bound `[isTreeNodeDeletable]="true"` unconditionally.
- On **both** the dataset and the model page, an image row showed "Set
as cover", because the shared file tree gated that button on
`isImageFile(...)` and nothing else. Clicking it returned a red "User
has no access to this dataset" toast.
Changes:
- `user-dataset-version-filetree.component.ts/.html` — a new
`isCoverSettable` input, defaulting to `false` exactly like
`isTreeNodeDeletable`, added to the "Set as cover" `*ngIf`.
- `dataset-detail.component.html` — bind both inputs to
`userHasWriteAccess()`, the helper that already gates the Settings tab,
instead of the hardcoded `true`.
- `model-detail.component.html` — bind the new input to its own
`userHasWriteAccess()`. The model page already gated delete this way.
The third consumer of the tree, `dataset-selection-modal`, binds neither
input and so now renders no "Set as cover" button — it never wired the
output up, so that button did nothing at all before.
Verified against a local stack with a dataset and a model shared
READ-only with a second user:
| | delete icons | "Set as cover" |
| --- | --- | --- |
| dataset, READ user, before | 4 | 1 |
| dataset, READ user, after | 0 | 0 |
| model, READ user, before | 0 | 1 |
| model, READ user, after | 0 | 0 |
| dataset, owner, after | 4 | 1 |
**Before** — READ-only collaborator sees a trash icon on every file, and
"Set as cover" gives a 403:
<img width="1440" height="900" alt="image"
src="https://github.com/user-attachments/assets/5ca2c137-2457-4a8b-8178-2a0347fab0d7"
/>
<img width="1440" height="900" alt="image"
src="https://github.com/user-attachments/assets/227c2547-a332-42ea-97a6-9560bc2d7435"
/>
**After** — the same user, same dataset, no write controls (the owner's
view is unchanged):
<img width="1440" height="900" alt="image"
src="https://github.com/user-attachments/assets/87d76d20-0e18-4017-a9d4-045b1615c4b0"
/>
### Any related issues, documentation, discussions?
Closes #8349.
### How was this PR tested?
Specs added or updated:
- `user-dataset-version-filetree.component.spec.ts` — new `withholds
Set-as-cover unless the host allows it`; the existing `offers
Set-as-cover on image files only` now opts in through the new input. 18
passed.
- `dataset-detail.component.spec.ts` — new `offers the tree's write
controls only to a writer`, asserting both inputs follow WRITE/READ. 143
passed.
- `model-detail.component.spec.ts` — the same case for the model page.
81 passed.
- `dataset-selection-modal.component.spec.ts` — unchanged, 16 passed.
```
cd frontend
npx ng test --include
src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.spec.ts
npx ng test --include
src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
npx ng test --include
src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
```
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---
.../dataset-detail.component.html | 3 ++-
.../dataset-detail.component.spec.ts | 16 ++++++++++++++++
.../user-dataset-version-filetree.component.html | 2 +-
.../user-dataset-version-filetree.component.spec.ts | 7 +++++++
.../user-dataset-version-filetree.component.ts | 3 +++
.../user-model-explorer/model-detail.component.html | 1 +
.../model-detail.component.spec.ts | 19 +++++++++++++++++++
7 files changed, 49 insertions(+), 2 deletions(-)
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
index 1053ea7c27..9aa0a60c09 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
@@ -439,7 +439,8 @@
</div>
<texera-user-dataset-version-filetree
[fileTreeNodes]="fileTreeNodeList"
- [isTreeNodeDeletable]="true"
+ [isTreeNodeDeletable]="userHasWriteAccess()"
+ [isCoverSettable]="userHasWriteAccess()"
(selectedTreeNode)="onVersionFileTreeNodeSelected($event)"
(deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"
(setCoverImage)="onSetCoverImage($event)">
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
index 11be93b137..0c8d4a6de5 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -2527,6 +2527,22 @@ describe("DatasetDetailComponent rendered template", ()
=> {
expect(datasetService.deleteDatasetFile).toHaveBeenCalledWith(5,
"nested/b.csv");
});
+ it("offers the tree's write controls only to a writer", () => {
+ // A fresh page per access level: the Settings tab is gated on write
access, so flipping the
+ // level on a live component removes a tab and nz-tabs can tear down the
pane being asserted on.
+ const treeFor = (level: "READ" | "WRITE"): DebugElement => {
+ render({ userDatasetAccessLevel: level });
+ openTab("Versions & Files");
+ return tree();
+ };
+
+
expect(treeFor("WRITE").componentInstance.isTreeNodeDeletable).toBe(true);
+ expect(treeFor("WRITE").componentInstance.isCoverSettable).toBe(true);
+
+
expect(treeFor("READ").componentInstance.isTreeNodeDeletable).toBe(false);
+ expect(treeFor("READ").componentInstance.isCoverSettable).toBe(false);
+ });
+
it("adopts the cover image the tree offered, qualified by the selected
version", () => {
tree().triggerEventHandler("setCoverImage", "nested/b.png");
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
index a120082e40..1b64346316 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
@@ -55,7 +55,7 @@
<button
nz-button
nzType="link"
- *ngIf="!node.data.children && isImageFile(node.data.name)"
+ *ngIf="isCoverSettable && !node.data.children &&
isImageFile(node.data.name)"
class="icon-button"
nz-tooltip="Set as cover"
(click)="onSetCover(node.data)">
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.spec.ts
index ff9e82609a..38c2397353 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.spec.ts
@@ -241,8 +241,15 @@ describe("UserDatasetVersionFiletreeComponent", () => {
expect(fixture.nativeElement.querySelector("i[nztype='delete']")).toBeNull();
});
+ it("withholds Set-as-cover unless the host allows it", () => {
+ renderRows([file("photo.png")]);
+
+
expect(fixture.nativeElement.querySelector("i[nztype='picture']")).toBeNull();
+ });
+
it("offers Set-as-cover on image files only", () => {
const covers: string[] = [];
+ component.isCoverSettable = true;
component.setCoverImage.subscribe((path: string) => covers.push(path));
renderRows([file("photo.png"), file("data.csv")]);
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
index 885feceb88..a365864333 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
@@ -69,6 +69,9 @@ export class UserDatasetVersionFiletreeComponent implements
AfterViewInit {
@Input()
public isTreeNodeDeletable: boolean = false;
+ @Input()
+ public isCoverSettable: boolean = false;
+
@Input()
public set fileTreeNodes(nodes: DatasetFileNode[]) {
this._fileTreeNodes = nodes ?? [];
diff --git
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
index 97515e372d..928ca5f5ba 100644
---
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
+++
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
@@ -349,6 +349,7 @@
<texera-user-dataset-version-filetree
[fileTreeNodes]="fileTreeNodeList"
[isTreeNodeDeletable]="userHasWriteAccess()"
+ [isCoverSettable]="userHasWriteAccess()"
(selectedTreeNode)="onVersionFileTreeNodeSelected($event)"
(deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"
(setCoverImage)="onSetCoverImage($event)">
diff --git
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
index 3e5ab819f7..617b2534f6 100644
---
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
@@ -559,6 +559,25 @@ describe("ModelDetailComponent", () => {
// The panel itself is covered by version-uploader.component.spec.ts; what
matters here is that
// the page hands it the model's own addressing, and what the page still
owns around it.
+ it("offers the tree's write controls only to a writer", () => {
+ // A fresh page per access level: the Settings tab is gated on write
access, so flipping the
+ // level on a live component removes a tab and nz-tabs can tear down the
pane being asserted on.
+ const treeFor = (level: "READ" | "WRITE") => {
+ create();
+ component.userModelAccessLevel = level;
+ openTab("Versions & Files");
+ const filetree =
fixture.debugElement.query(By.css("texera-user-dataset-version-filetree"));
+ expect(filetree, "expected the file tree to be rendered").not.toBeNull();
+ return filetree.componentInstance;
+ };
+
+ expect(treeFor("WRITE").isTreeNodeDeletable).toBe(true);
+ expect(treeFor("WRITE").isCoverSettable).toBe(true);
+
+ expect(treeFor("READ").isTreeNodeDeletable).toBe(false);
+ expect(treeFor("READ").isCoverSettable).toBe(false);
+ });
+
it("hands the version uploader the model endpoint and the model's identity",
() => {
create();
const root = openTab("Versions & Files");