Skip to content

Commit ed5ed06

Browse files
[OP-19885] User cannot update own profile because password confirmation autosaves
https://community.openproject.org/wp/OP-19885
1 parent 971961f commit ed5ed06

5 files changed

Lines changed: 127 additions & 31 deletions

File tree

frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,36 @@ describe('CkeditorAugmentedTextareaComponent', () => {
7373

7474
expect(sync).toHaveBeenCalledTimes(1);
7575
});
76+
77+
describe('form submit interception', () => {
78+
let form:HTMLFormElement;
79+
let sync:ReturnType<typeof vi.spyOn>;
80+
let saveForm:ReturnType<typeof vi.spyOn>;
81+
82+
beforeEach(() => {
83+
form = document.createElement('form');
84+
component.formElement = form;
85+
sync = vi.spyOn(component, 'syncToTextarea').mockImplementation(() => undefined);
86+
saveForm = vi.spyOn(component, 'saveForm').mockResolvedValue(undefined);
87+
(component as unknown as { registerFormSubmitListener():void }).registerFormSubmitListener();
88+
});
89+
90+
it('delegates to saveForm when submit is not already prevented', () => {
91+
const event = new SubmitEvent('submit', { cancelable: true, bubbles: true });
92+
form.dispatchEvent(event);
93+
94+
expect(event.defaultPrevented).toBe(true);
95+
expect(saveForm).toHaveBeenCalledWith(event);
96+
expect(sync).not.toHaveBeenCalled();
97+
});
98+
99+
it('only syncs when another handler already prevented default', () => {
100+
const event = new SubmitEvent('submit', { cancelable: true, bubbles: true });
101+
event.preventDefault();
102+
form.dispatchEvent(event);
103+
104+
expect(saveForm).not.toHaveBeenCalled();
105+
expect(sync).toHaveBeenCalledTimes(1);
106+
});
107+
});
76108
});

frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl
178178
this.untilDestroyed(),
179179
)
180180
.subscribe((evt:SubmitEvent) => {
181+
// Another handler (e.g. require-password-confirmation) already owns this
182+
// submit. Still flush editor → textarea so a later confirmed submit has
183+
// the latest content, but do not re-submit — Turbo's navigator.submitForm
184+
// would bypass that other handler and POST without confirmation.
185+
if (evt.defaultPrevented) {
186+
this.syncToTextarea();
187+
return;
188+
}
189+
181190
evt.preventDefault();
182191
void this.saveForm(evt);
183192
});
@@ -214,7 +223,11 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl
214223
(evt.submitter as HTMLInputElement).disabled = false;
215224
}
216225

217-
if (this.turboMode && !this.formElement.dataset.action) {
226+
// Honor the form's data-turbo="false" even when this component was created
227+
// with turboMode (Primer rich_text_area default). Turbo's submitForm skips
228+
// the submit event and would bypass other submit interceptors.
229+
const turboDisabled = this.formElement.dataset.turbo === 'false';
230+
if (this.turboMode && !turboDisabled && !this.formElement.dataset.action) {
218231
navigator.submitForm(this.formElement, evt?.submitter ?? undefined);
219232
} else {
220233
this.formElement.requestSubmit(evt?.submitter);

frontend/src/stimulus/controllers/require-password-confirmation.controller.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,21 @@ describe('Require password confirmation controller', () => {
102102
});
103103
});
104104

105+
it('intercepts submit in the capture phase before bubble listeners', async () => {
106+
const form = await renderForm();
107+
const bubbleOrder:string[] = [];
108+
109+
form.addEventListener('submit', (event) => {
110+
bubbleOrder.push(`bubble:prevented=${event.defaultPrevented}`);
111+
});
112+
113+
const event = new SubmitEvent('submit', { cancelable: true, bubbles: true });
114+
form.dispatchEvent(event);
115+
116+
expect(event.defaultPrevented).toBe(true);
117+
expect(bubbleOrder).toEqual(['bubble:prevented=true']);
118+
});
119+
105120
it('appends the confirmed password and resubmits the form', async () => {
106121
const form = await renderForm();
107122
const requestSubmit = vi.fn();

frontend/src/stimulus/controllers/require-password-confirmation.controller.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ export default class RequirePasswordConfirmationController extends ApplicationCo
5454
connect() {
5555
super.connect();
5656

57-
this.element.addEventListener('submit', this.formListener);
57+
// Capture phase so we run before other submit interceptors on the same form
58+
// (notably CKEditor-augmented textareas), which can otherwise re-submit via
59+
// Turbo and bypass the confirmation dialog.
60+
this.element.addEventListener('submit', this.formListener, { capture: true });
5861
document.addEventListener('password-confirmation-dialog:close', this.dialogCloseListener);
5962
document.addEventListener('password-confirmation-dialog:submit', this.dialogSubmitListener);
6063

@@ -66,7 +69,7 @@ export default class RequirePasswordConfirmationController extends ApplicationCo
6669
disconnect() {
6770
super.disconnect();
6871

69-
this.element.removeEventListener('submit', this.formListener);
72+
this.element.removeEventListener('submit', this.formListener, { capture: true });
7073
document.removeEventListener('password-confirmation-dialog:close', this.dialogCloseListener);
7174
document.removeEventListener('password-confirmation-dialog:submit', this.dialogSubmitListener);
7275
}

spec/features/users/my_spec.rb

Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -143,44 +143,77 @@ def expect_changed!
143143
describe "#account" do
144144
let(:dialog) { Components::PasswordConfirmationDialog.new }
145145

146-
before do
147-
visit my_account_path
146+
context "when updating profile fields" do
147+
before do
148+
visit my_account_path
149+
150+
fill_in "user[mail]", with: "foo@mail.com"
151+
fill_in "user[firstname]", with: "Foo"
152+
fill_in "user[lastname]", with: "Bar"
153+
click_on "Update profile"
154+
end
148155

149-
fill_in "user[mail]", with: "foo@mail.com"
150-
fill_in "user[firstname]", with: "Foo"
151-
fill_in "user[lastname]", with: "Bar"
152-
click_on "Update profile"
153-
end
156+
context "when confirmation disabled",
157+
with_config: { internal_password_confirmation: false } do
158+
it "does not request confirmation" do
159+
expect_changed!
160+
end
161+
end
154162

155-
context "when confirmation disabled",
156-
with_config: { internal_password_confirmation: false } do
157-
it "does not request confirmation" do
158-
expect_changed!
163+
context "when confirmation required",
164+
with_config: { internal_password_confirmation: true } do
165+
it "requires the password for a regular user" do
166+
dialog.confirm_flow_with(user_password)
167+
expect_changed!
168+
end
169+
170+
it "declines the change when invalid password is given" do
171+
dialog.confirm_flow_with("#{user_password}INVALID", should_fail: true)
172+
173+
user.reload
174+
expect(user.mail).to eq("old@mail.com")
175+
end
176+
177+
context "as admin" do
178+
shared_let(:admin) { create(:admin) }
179+
let(:user) { admin }
180+
181+
it "requires the password" do
182+
dialog.confirm_flow_with("adminADMIN!")
183+
expect_changed!
184+
end
185+
end
159186
end
160187
end
161188

162-
context "when confirmation required",
189+
# CKEditor-augmented text custom fields also intercept submit (to flush
190+
# editor → textarea). With turboMode they used to call Turbo's
191+
# navigator.submitForm, which bypassed password confirmation and POSTed
192+
# immediately — flashing notice_password_confirmation_failed.
193+
context "with a long text custom field",
163194
with_config: { internal_password_confirmation: true } do
164-
it "requires the password for a regular user" do
165-
dialog.confirm_flow_with(user_password)
166-
expect_changed!
167-
end
195+
let!(:text_cf) { create(:user_custom_field, :text, name: "Biography") }
196+
let(:editor) { Components::WysiwygEditor.new("[data-test-selector='#{text_cf.attribute_name(:kebab_case)}']") }
168197

169-
it "declines the change when invalid password is given" do
170-
dialog.confirm_flow_with(user_password + "INVALID", should_fail: true)
198+
it "still requires password confirmation and does not submit without it" do
199+
visit my_account_path
171200

172-
user.reload
173-
expect(user.mail).to eq("old@mail.com")
174-
end
201+
editor.expect_value("")
202+
editor.set_markdown("Loves hiking")
175203

176-
context "as admin" do
177-
shared_let(:admin) { create(:admin) }
178-
let(:user) { admin }
204+
fill_in "user[mail]", with: "foo@mail.com"
205+
fill_in "user[firstname]", with: "Foo"
206+
fill_in "user[lastname]", with: "Bar"
207+
click_on "Update profile"
179208

180-
it "requires the password" do
181-
dialog.confirm_flow_with("adminADMIN!")
182-
expect_changed!
183-
end
209+
dialog.expect_open
210+
expect(page).to have_no_text(I18n.t(:notice_password_confirmation_failed))
211+
212+
dialog.confirm_flow_with(user_password)
213+
expect_changed!
214+
215+
user.reload
216+
expect(user.typed_custom_value_for(text_cf)).to include("Loves hiking")
184217
end
185218
end
186219
end

0 commit comments

Comments
 (0)