diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb
index c92ee82c1f11..60a06c646d8a 100644
--- a/config/initializers/permissions.rb
+++ b/config/initializers/permissions.rb
@@ -301,7 +301,8 @@
{},
permissible_on: :project,
require: :loggedin,
- dependencies: :view_work_packages
+ dependencies: :view_work_packages,
+ contract_actions: { queries: %i[create] }
# Watchers
wpt.permission :view_work_package_watchers,
{},
diff --git a/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html b/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html
index ac5661935924..22966a2428a6 100644
--- a/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html
+++ b/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html
@@ -1,5 +1,5 @@
diff --git a/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts
index 2c80f6ee68e7..e4d42f05515d 100644
--- a/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts
+++ b/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts
@@ -37,16 +37,18 @@ import { GridAreaService } from 'core-app/shared/components/grids/grid/area.serv
export abstract class WidgetAbstractMenuComponent {
@Input() resource:GridWidgetResource;
- protected menuItemList:OpContextMenuItem[] = [this.removeItem];
-
constructor(readonly injector:Injector,
readonly i18n:I18nService,
protected readonly remove:GridRemoveWidgetService,
protected readonly layout:GridAreaService) {
}
- public get menuItems() {
- return async () => this.menuItemList;
+ public get menuItemsFactory():() => Promise {
+ return this.buildItems.bind(this);
+ }
+
+ protected async buildItems():Promise {
+ return [this.removeItem];
}
protected get removeItem():OpContextMenuItem {
diff --git a/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html b/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html
index 7c5027236947..450fd0363d22 100644
--- a/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html
+++ b/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html
@@ -1,4 +1,4 @@
+ [menuItemsFactory]="menuItemsFactory">
diff --git a/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts
index 7678eb2c5e79..7f0b1347d30b 100644
--- a/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts
+++ b/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts
@@ -26,15 +26,20 @@
// See COPYRIGHT and LICENSE files for more details.
//++
-import {
- Directive, EventEmitter, Output,
-} from '@angular/core';
+import { Directive, EventEmitter, Output } from '@angular/core';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { ComponentType } from '@angular/cdk/portal';
-import { WidgetAbstractMenuComponent } from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component';
-import { WpGraphConfigurationModalComponent } from 'core-app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal';
-import { WpTableConfigurationModalComponent } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal';
+import {
+ WidgetAbstractMenuComponent,
+} from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component';
+import {
+ WpGraphConfigurationModalComponent,
+} from 'core-app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal';
+import {
+ WpTableConfigurationModalComponent,
+} from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
+import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types';
@Directive()
export abstract class WidgetWpSetMenuComponent extends WidgetAbstractMenuComponent {
@@ -42,12 +47,20 @@ export abstract class WidgetWpSetMenuComponent extends WidgetAbstractMenuCompone
@InjectField() opModalService:OpModalService;
- @Output() onConfigured:EventEmitter = new EventEmitter();
+ // eslint-disable-next-line @angular-eslint/no-output-on-prefix
+ @Output() onConfigured:EventEmitter = new EventEmitter();
+
+ protected async buildItems():Promise {
+ const items = [
+ this.removeItem,
+ ];
- protected menuItemList = [
- this.removeItem,
- this.configureItem,
- ];
+ if (await this.configurationAllowed()) {
+ items.push(this.configureItem);
+ }
+
+ return items;
+ }
protected get configureItem() {
return {
@@ -65,6 +78,10 @@ export abstract class WidgetWpSetMenuComponent extends WidgetAbstractMenuCompone
};
}
+ protected configurationAllowed():Promise {
+ return Promise.resolve(true);
+ }
+
protected get locals() {
return {};
}
diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts
index 1085aa5705f6..6580e1d5973d 100644
--- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts
+++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts
@@ -30,7 +30,9 @@ import {
Component,
OnInit,
} from '@angular/core';
-import { WidgetAbstractMenuComponent } from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component';
+import {
+ WidgetAbstractMenuComponent,
+} from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component';
import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
@@ -60,16 +62,14 @@ export class WidgetProjectDetailsMenuComponent extends WidgetAbstractMenuCompone
);
}
- public get menuItems() {
- return async () => {
- const items = [
- this.removeItem,
- ];
- if (await this.capabilityPromise) {
- items.push(this.projectActivityLinkItem);
- }
- return items;
- };
+ protected async buildItems():Promise {
+ const items = [
+ this.removeItem,
+ ];
+ if (await this.capabilityPromise) {
+ items.push(this.projectActivityLinkItem);
+ }
+ return items;
}
protected get projectActivityLinkItem():OpContextMenuItem {
diff --git a/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts
index 2a5e6c0cde2e..3803991f8b1e 100644
--- a/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts
+++ b/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts
@@ -30,9 +30,14 @@ import {
Component, EventEmitter, Output,
} from '@angular/core';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
-import { WidgetAbstractMenuComponent } from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component';
-import { TimeEntriesCurrentUserConfigurationModalComponent } from 'core-app/shared/components/grids/widgets/time-entries/current-user/configuration-modal/configuration.modal';
+import {
+ WidgetAbstractMenuComponent,
+} from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component';
+import {
+ TimeEntriesCurrentUserConfigurationModalComponent,
+} from 'core-app/shared/components/grids/widgets/time-entries/current-user/configuration-modal/configuration.modal';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
+import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types';
@Component({
selector: 'widget-time-entries-current-user-menu',
@@ -43,10 +48,12 @@ export class WidgetTimeEntriesCurrentUserMenuComponent extends WidgetAbstractMen
@Output() onConfigured:EventEmitter = new EventEmitter();
- protected menuItemList = [
- this.removeItem,
- this.configureItem,
- ];
+ protected async buildItems():Promise {
+ return [
+ this.removeItem,
+ this.configureItem,
+ ];
+ }
protected get configureItem() {
return {
diff --git a/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts
index 518b7e786976..a5f36239088e 100644
--- a/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts
+++ b/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts
@@ -27,13 +27,26 @@
//++
import { Component } from '@angular/core';
-import { WpTableConfigurationModalComponent } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal';
+import {
+ WpTableConfigurationModalComponent,
+} from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal';
import { WidgetWpSetMenuComponent } from 'core-app/shared/components/grids/widgets/menu/wp-set-menu.component';
+import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
+import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
+import { firstValueFrom } from 'rxjs';
@Component({
selector: 'widget-wp-table-menu',
templateUrl: '../menu/widget-menu.component.html',
})
export class WidgetWpTableMenuComponent extends WidgetWpSetMenuComponent {
+ @InjectField() currentUser:CurrentUserService;
+
protected configurationComponent = WpTableConfigurationModalComponent;
+
+ protected configurationAllowed():Promise {
+ return firstValueFrom(
+ this.currentUser.hasCapabilities$('queries/create', null),
+ );
+ }
}
diff --git a/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts b/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts
index dff1ff991c0a..9e5e151e10db 100644
--- a/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts
+++ b/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts
@@ -26,11 +26,11 @@
// See COPYRIGHT and LICENSE files for more details.
//++
-import {
- ChangeDetectorRef, Component, ElementRef, Injector, Input,
-} from '@angular/core';
+import { ChangeDetectorRef, Component, ElementRef, Injector, Input } from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
-import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive';
+import {
+ OpContextMenuTrigger,
+} from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive';
import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types';
@@ -41,16 +41,18 @@ import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op
styleUrls: ['./icon-triggered-context-menu.component.sass'],
})
export class IconTriggeredContextMenuComponent extends OpContextMenuTrigger {
- constructor(readonly elementRef:ElementRef,
+ constructor(
+ readonly elementRef:ElementRef,
readonly opContextMenu:OPContextMenuService,
readonly opModalService:OpModalService,
readonly injector:Injector,
readonly cdRef:ChangeDetectorRef,
- readonly I18n:I18nService) {
+ readonly I18n:I18nService,
+ ) {
super(elementRef, opContextMenu);
}
- @Input('menu-items') menuItems:Function;
+ @Input() menuItemsFactory:() => Promise;
protected async open(evt:JQuery.TriggeredEvent) {
this.items = await this.buildItems();
@@ -78,8 +80,8 @@ export class IconTriggeredContextMenuComponent extends OpContextMenuTrigger {
const items:OpContextMenuItem[] = [];
// Add action specific menu entries
- if (this.menuItems) {
- const additional = await this.menuItems();
+ if (this.menuItemsFactory) {
+ const additional = await this.menuItemsFactory();
return items.concat(additional);
}
diff --git a/modules/my_page/lib/my_page/grid_registration.rb b/modules/my_page/lib/my_page/grid_registration.rb
index fca2a6304586..665f6e1bfb8b 100644
--- a/modules/my_page/lib/my_page/grid_registration.rb
+++ b/modules/my_page/lib/my_page/grid_registration.rb
@@ -23,11 +23,19 @@ class GridRegistration < ::Grids::Configuration::Registration
options_representer "::API::V3::Grids::Widgets::QueryOptionsRepresenter"
end
+ # Allow users without save_queries permission to access the widgets
+ # but they are not allowed to update the underlying query
+ wp_static_table_strategy_proc = Proc.new do
+ after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
+
+ options_representer "::API::V3::Grids::Widgets::QueryOptionsRepresenter"
+ end
+
widget_strategy "work_packages_table", &wp_table_strategy_proc
- widget_strategy "work_packages_assigned", &wp_table_strategy_proc
- widget_strategy "work_packages_accountable", &wp_table_strategy_proc
- widget_strategy "work_packages_watched", &wp_table_strategy_proc
- widget_strategy "work_packages_created", &wp_table_strategy_proc
+ widget_strategy "work_packages_assigned", &wp_static_table_strategy_proc
+ widget_strategy "work_packages_accountable", &wp_static_table_strategy_proc
+ widget_strategy "work_packages_watched", &wp_static_table_strategy_proc
+ widget_strategy "work_packages_created", &wp_static_table_strategy_proc
widget_strategy "time_entries_current_user" do
options_representer "::API::V3::Grids::Widgets::TimeEntryCalendarOptionsRepresenter"
diff --git a/modules/my_page/spec/features/my/work_package_watcher_widget_spec.rb b/modules/my_page/spec/features/my/work_package_watcher_widget_spec.rb
new file mode 100644
index 000000000000..16dfbfde4ac5
--- /dev/null
+++ b/modules/my_page/spec/features/my/work_package_watcher_widget_spec.rb
@@ -0,0 +1,61 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+require_relative "../../support/pages/my/page"
+
+RSpec.describe "Work package watched widget on My page", :js do
+ shared_let(:user) { create(:user) }
+ shared_let(:non_member) { create(:non_member, permissions: [:view_work_packages]) }
+ shared_let(:project) { create(:project, public: true) }
+ shared_let(:work_package) do
+ create(:work_package,
+ project:,
+ subject: "Visible work package for non member",
+ author: user,
+ responsible: user)
+ end
+
+ let(:my_page) do
+ Pages::My::Page.new
+ end
+
+ before do
+ login_as user
+ work_package.add_watcher(user)
+
+ my_page.visit!
+ end
+
+ it "can add the watcher widget without being member anywhere (Regression #55838)" do
+ my_page.add_widget(1, 1, :within, "Work packages watched by me")
+
+ expect(page).to have_text(work_package.subject)
+ end
+end