diff --git a/app/assets/javascripts/ide/components/oauth_application_callout.vue b/app/assets/javascripts/ide/components/oauth_application_callout.vue new file mode 100644 index 0000000000000000000000000000000000000000..89805a434bfab27092cf11644b4129995046029e --- /dev/null +++ b/app/assets/javascripts/ide/components/oauth_application_callout.vue @@ -0,0 +1,93 @@ + + diff --git a/app/assets/javascripts/ide/components/reset_application_settings_modal.vue b/app/assets/javascripts/ide/components/reset_application_settings_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..45442f04068cecd56597ae8fb7236d09d9628ff6 --- /dev/null +++ b/app/assets/javascripts/ide/components/reset_application_settings_modal.vue @@ -0,0 +1,103 @@ + + diff --git a/app/assets/javascripts/ide/oauth_application_callout.js b/app/assets/javascripts/ide/oauth_application_callout.js new file mode 100644 index 0000000000000000000000000000000000000000..a9d336c7ff2c7a6e2db957b723cb3359b30e2d5c --- /dev/null +++ b/app/assets/javascripts/ide/oauth_application_callout.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import WebIdeOAuthApplicationCallout from './components/oauth_application_callout.vue'; + +export const initWebIdeOAuthApplicationCallout = () => { + const el = document.querySelector('#web_ide_oauth_application_callout'); + + if (!el) { + return null; + } + + const { redirectUrlPath, resetApplicationSettingsPath } = el.dataset; + + return new Vue({ + el, + name: 'WebIdeOAuthApplicationCallout', + render(h) { + return h(WebIdeOAuthApplicationCallout, { + props: { + redirectUrlPath, + resetApplicationSettingsPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js index df9e38431b06a99cc58f74c3a41a19b13315c81d..3d52ea1530a45f3e38ed6099139434a3e1f0a8a9 100644 --- a/app/assets/javascripts/pages/admin/applications/index.js +++ b/app/assets/javascripts/pages/admin/applications/index.js @@ -1,5 +1,7 @@ import initApplicationDeleteButtons from '~/admin/applications'; import { initOAuthApplicationSecret } from '~/oauth_application'; +import { initWebIdeOAuthApplicationCallout } from '~/ide/oauth_application_callout'; initApplicationDeleteButtons(); initOAuthApplicationSecret(); +initWebIdeOAuthApplicationCallout(); diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index d97fcc5df7483b51f97370b9884e67c8fa03d1fb..c9419354c2324298a75274aba30e1ab57f29b472 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -58,6 +58,14 @@ def destroy redirect_to admin_applications_url, status: :found, notice: _('Application was successfully destroyed.') end + def reset_web_ide_oauth_application_settings + success = ::WebIde::DefaultOauthApplication.reset_oauth_application_settings + + return render json: {}, status: :internal_server_error unless success + + render json: {} + end + private def set_application diff --git a/app/views/admin/applications/_web_ide_oauth_application_callout.html.haml b/app/views/admin/applications/_web_ide_oauth_application_callout.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..aa72307a090147cb1e7ac836e75bdcb1f155d50e --- /dev/null +++ b/app/views/admin/applications/_web_ide_oauth_application_callout.html.haml @@ -0,0 +1,3 @@ +- return unless web_ide_oauth_application_id == application.id + +#web_ide_oauth_application_callout{ :data => { :redirect_url_path => ide_oauth_redirect_path, :reset_application_settings_path => ide_reset_oauth_application_settings_path } } diff --git a/app/views/admin/applications/edit.html.haml b/app/views/admin/applications/edit.html.haml index 10a27fb906fd0247f6906765f8d9c56f3e5a66a1..d0ecba3f977c82f94100b086c22c38a0d18aad4c 100644 --- a/app/views/admin/applications/edit.html.haml +++ b/app/views/admin/applications/edit.html.haml @@ -2,6 +2,8 @@ - breadcrumb_title @application.name - page_title _("Edit"), @application.name, _("Applications") += render 'web_ide_oauth_application_callout', application: @application + %h1.page-title.gl-font-size-h-display = _('Edit application') - @url = admin_application_path(@application) diff --git a/config/routes.rb b/config/routes.rb index 868934605a9e28083ee56c367cc1b8481c197e52..c4eeaebc94a53f4aa78b3b9aef9097a4341045a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -164,6 +164,8 @@ as: :remote, to: 'web_ide/remote_ide#index', constraints: { remote_host: %r{[^/?]+} } + + post '/reset_oauth_application_settings' => 'admin/applications#reset_web_ide_oauth_application_settings' end draw :operations diff --git a/lib/web_ide/default_oauth_application.rb b/lib/web_ide/default_oauth_application.rb index b9108914c9a96f3e371a444ea1d8b79c8d58ec09..d7b5a3a3b494b2ab283067e50685668ae6a6ea8a 100644 --- a/lib/web_ide/default_oauth_application.rb +++ b/lib/web_ide/default_oauth_application.rb @@ -25,6 +25,12 @@ def oauth_application_callback_urls URI.extract(oauth_application.redirect_uri, %w[http https]).uniq end + def reset_oauth_application_settings + return unless oauth_application + + oauth_application.update!(default_settings) + end + def ensure_oauth_application! return if oauth_application @@ -35,17 +41,12 @@ def ensure_oauth_application! # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132496#note_1587293087 application_settings.lock! - # note: `lock!`` breaks applicaiton_settings cache and will trigger another query. + # note: `lock!`` breaks application_settings cache and will trigger another query. # We need to double check here so that requests previously waiting on the lock can # now just skip. next if oauth_application - application = Doorkeeper::Application.new( - name: 'GitLab Web IDE', - redirect_uri: oauth_callback_url, - scopes: ['api'], - trusted: true, - confidential: false) + application = Doorkeeper::Application.new(default_settings) application.save! application_settings.update!(web_ide_oauth_application: application) should_expire_cache = true @@ -60,6 +61,16 @@ def ensure_oauth_application! def application_settings ::Gitlab::CurrentSettings.current_application_settings end + + def default_settings + { + "name" => 'GitLab Web IDE', + "redirect_uri" => oauth_callback_url, + "scopes" => ['api'], + "trusted" => true, + "confidential" => false + }.freeze + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 655ee11de12378c3c43ed801060f6f3151898657..e86c74083f84752886064b1919ae402754bf1511 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -26513,6 +26513,15 @@ msgstr "" msgid "IDE" msgstr "" +msgid "IDE|An error occurred while restoring the application to default. Please try again." +msgstr "" + +msgid "IDE|Are you sure you want to restore this application to its original configuration? All your changes will be lost." +msgstr "" + +msgid "IDE|Cancel" +msgstr "" + msgid "IDE|Cannot open Web IDE" msgstr "" @@ -26522,12 +26531,18 @@ msgstr "" msgid "IDE|Commit to %{branchName} branch" msgstr "" +msgid "IDE|Confirm" +msgstr "" + msgid "IDE|Contact your administrator or try to open the Web IDE again with another domain." msgstr "" msgid "IDE|Edit" msgstr "" +msgid "IDE|Editing this application might affect the functionality of the Web IDE. Ensure the configuration meets the following conditions:" +msgstr "" + msgid "IDE|Error reloading page" msgstr "" @@ -26546,6 +26561,12 @@ msgstr "" msgid "IDE|Reopen with other domain" msgstr "" +msgid "IDE|Restore application to default" +msgstr "" + +msgid "IDE|Restore to default" +msgstr "" + msgid "IDE|Review" msgstr "" @@ -26555,9 +26576,21 @@ msgstr "" msgid "IDE|Successful commit" msgstr "" +msgid "IDE|The %{boldStart}Confidential%{boldEnd} checkbox is cleared." +msgstr "" + +msgid "IDE|The %{boldStart}Trusted%{boldEnd} checkbox is selected." +msgstr "" + +msgid "IDE|The %{boldStart}api%{boldEnd} scope is selected." +msgstr "" + msgid "IDE|The URL you're using to access the Web IDE and the configured OAuth callback URL do not match. This issue often occurs when you're using a proxy." msgstr "" +msgid "IDE|The redirect URI path is %{codeBlockStart}%{pathFormat}%{codeBlockEnd}. An example of a valid redirect URI is %{codeBlockStart}%{example}%{codeBlockEnd}." +msgstr "" + msgid "IDE|This option is disabled because you are not allowed to create merge requests in this project." msgstr "" diff --git a/spec/controllers/admin/applications_controller_spec.rb b/spec/controllers/admin/applications_controller_spec.rb index 1feda0ed36ff1363a9bbe3abb19e0b2341fafc1f..fa15ba607f7da46e74b379829b6b5afc2a16a0ac 100644 --- a/spec/controllers/admin/applications_controller_spec.rb +++ b/spec/controllers/admin/applications_controller_spec.rb @@ -152,4 +152,29 @@ end end end + + describe "#reset_oauth_application_settings" do + subject(:reset_oauth_application_settings) { post :reset_web_ide_oauth_application_settings } + + before do + stub_feature_flags(web_ide_oauth: true) + end + + it 'returns 500 if no oauth application exists' do + stub_application_setting(web_ide_oauth_application: nil) + reset_oauth_application_settings + + expect(response).to have_gitlab_http_status(:internal_server_error) + end + + it 'returns 200 if oauth application exists' do + stub_application_setting({ + web_ide_oauth_application: create(:oauth_application, owner_id: nil, owner_type: nil) + }) + + reset_oauth_application_settings + + expect(response).to have_gitlab_http_status(:ok) + end + end end diff --git a/spec/frontend/ide/components/oauth_application_callout_spec.js b/spec/frontend/ide/components/oauth_application_callout_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fd74c90789cd4c3b49b11ebea555b9435e99d71f --- /dev/null +++ b/spec/frontend/ide/components/oauth_application_callout_spec.js @@ -0,0 +1,56 @@ +import { GlAlert, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import WebIdeOAuthApplicationCallout, { + I18N_WEB_IDE_OAUTH_APPLICATION_CALLOUT, +} from '~/ide/components/oauth_application_callout.vue'; +import ResetApplicationSettingsModal from '~/ide/components/reset_application_settings_modal.vue'; + +const MOCK_IDE_REDIRECT_PATH = '/ide/oauth_redirect'; +const MOCK_IDE_RESET_APPLICATION_SETTINGS_PATH = '/ide/reset_application_settings'; + +describe('WebIdeOAuthApplicationCallout', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findButton = () => wrapper.findComponent(GlButton); + const findModal = () => wrapper.findComponent(ResetApplicationSettingsModal); + + const createWrapper = () => { + wrapper = mount(WebIdeOAuthApplicationCallout, { + propsData: { + redirectUrlPath: MOCK_IDE_REDIRECT_PATH, + resetApplicationSettingsPath: MOCK_IDE_RESET_APPLICATION_SETTINGS_PATH, + }, + }); + }; + + beforeEach(() => { + createWrapper(); + }); + + it('renders alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findButton().text()).toBe(I18N_WEB_IDE_OAUTH_APPLICATION_CALLOUT.alertButtonText); + }); + + it('shows reset application settings modal on restore button click', async () => { + findButton().vm.$emit('click'); + await nextTick(); + const modal = findModal(); + expect(modal.exists()).toBe(true); + expect(modal.props('visible')).toBe(true); + }); + + it.each(['close', 'cancel'])( + 'hides reset application settings modal on close or cancel', + async (eventName) => { + findModal().vm.$emit(eventName); + await nextTick(); + + const modal = findModal(); + expect(modal.exists()).toBe(true); + expect(modal.props('visible')).toBe(false); + }, + ); +}); diff --git a/spec/frontend/ide/components/reset_application_settings_modal_spec.js b/spec/frontend/ide/components/reset_application_settings_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ca818e541f4242b7c39b6120acb711bf4a87a744 --- /dev/null +++ b/spec/frontend/ide/components/reset_application_settings_modal_spec.js @@ -0,0 +1,118 @@ +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert, GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_NO_CONTENT, HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status'; +import ResetApplicationSettingsModal, { + I18N_RESET_APPLICATION_SETTINGS_MODAL, +} from '~/ide/components/reset_application_settings_modal.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; + +const mockEvent = { preventDefault: jest.fn() }; +const mockHide = jest.fn(); +const MOCK_RESET_APPLICATION_SETTINGS_PATH = '/reset_application_settings_path'; + +describe('ResetApplicationSettingsModal', () => { + useMockLocationHelper(); + + let mockAxios; + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const firePrimaryEvent = () => findModal().vm.$emit('primary', mockEvent); + + const createWrapper = () => { + wrapper = mount(ResetApplicationSettingsModal, { + propsData: { + visible: true, + resetApplicationSettingsPath: MOCK_RESET_APPLICATION_SETTINGS_PATH, + }, + stubs: { + GlModal: stubComponent(GlModal, { + methods: { + hide: mockHide, + }, + }), + }, + }); + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + + createWrapper(); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('renders modal with correct props', () => { + const modal = findModal(); + + expect(modal.props('modalId')).toBe('reset-application-settings-modal'); + expect(modal.props('title')).toEqual(I18N_RESET_APPLICATION_SETTINGS_MODAL.title); + expect(modal.props('visible')).toBe(true); + }); + + describe('resetApplicationSetings', () => { + it('makes request to reset application settings path', async () => { + mockAxios.onPost(MOCK_RESET_APPLICATION_SETTINGS_PATH).reply(HTTP_STATUS_NO_CONTENT); + + firePrimaryEvent(); + await waitForPromises(); + await nextTick(); + + expect(mockAxios.history.post.length).toBe(1); + expect(mockAxios.history.post[0].url).toBe(MOCK_RESET_APPLICATION_SETTINGS_PATH); + }); + + describe('on success', () => { + beforeEach(async () => { + mockAxios.onPost(MOCK_RESET_APPLICATION_SETTINGS_PATH).reply(HTTP_STATUS_NO_CONTENT); + + firePrimaryEvent(); + await waitForPromises(); + await nextTick(); + }); + + it('closes modal and reloads window upon successful reset', () => { + expect(mockHide).toHaveBeenCalledTimes(1); + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it('does not display error alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(async () => { + mockAxios.onPost(MOCK_RESET_APPLICATION_SETTINGS_PATH).reply(HTTP_STATUS_BAD_REQUEST); + + firePrimaryEvent(); + await waitForPromises(); + await nextTick(); + }); + + it('does not reload window upon error', () => { + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + it('displays error alert', () => { + const modal = findModal(); + const errorAlert = findAlert(); + + expect(modal.props('visible')).toBe(true); + expect(modal.props('actionPrimary').attributes.loading).toBe(false); + expect(errorAlert.exists()).toBe(true); + expect(errorAlert.text()).toBe(I18N_RESET_APPLICATION_SETTINGS_MODAL.errorMessage); + }); + }); + }); +}); diff --git a/spec/lib/web_ide/default_oauth_application_spec.rb b/spec/lib/web_ide/default_oauth_application_spec.rb index 4f051ffaafbedc8b80ca321c2a3c52ccaba7148e..dc2359e771da8db5af017dedb975d59666926d2a 100644 --- a/spec/lib/web_ide/default_oauth_application_spec.rb +++ b/spec/lib/web_ide/default_oauth_application_spec.rb @@ -110,6 +110,26 @@ end end + describe '#reset_oauth_application_settings' do + it 'resets oauth application settings to original' do + mock_bad_oauth_application = oauth_application + mock_bad_oauth_application["confidential"] = true + mock_bad_oauth_application["trusted"] = false + + stub_application_setting({ web_ide_oauth_application: mock_bad_oauth_application }) + + described_class.reset_oauth_application_settings + + expect(oauth_application).to have_attributes( + name: 'GitLab Web IDE', + redirect_uri: described_class.oauth_callback_url, + scopes: ['api'], + trusted: true, + confidential: false + ) + end + end + def application_settings ::Gitlab::CurrentSettings.current_application_settings end