diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js
index f527051f512..2418c785e0b 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js
@@ -167,6 +167,15 @@ export default Controller.extend({
return errorMessage && !updating;
},
+ @discourseComputed(
+ "model.remote_theme.remote_url",
+ "model.remote_theme.local_version",
+ "model.remote_theme.commits_behind"
+ )
+ finishInstall(remoteUrl, localVersion, commitsBehind) {
+ return remoteUrl && !localVersion && !commitsBehind;
+ },
+
editedFieldsForTarget(target) {
return this.get("model.editedFields").filter(
(field) => field.target === target
diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js
index df91acd0943..02907601804 100644
--- a/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js
+++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js
@@ -119,8 +119,12 @@ export default Controller.extend(ModalFunctionality, {
}
},
- @discourseComputed("selection")
- submitLabel(selection) {
+ @discourseComputed("selection", "themeCannotBeInstalled")
+ submitLabel(selection, themeCannotBeInstalled) {
+ if (themeCannotBeInstalled) {
+ return "admin.customize.theme.create_placeholder";
+ }
+
return `admin.customize.theme.${
selection === "create" ? "create" : "install"
}`;
@@ -216,6 +220,12 @@ export default Controller.extend(ModalFunctionality, {
}
}
+ // User knows that theme cannot be installed, but they want to continue
+ // to force install it.
+ if (this.themeCannotBeInstalled) {
+ options.data["force"] = true;
+ }
+
if (this.get("model.user_id")) {
// Used by theme-creator
options.data["user_id"] = this.get("model.user_id");
@@ -231,7 +241,16 @@ export default Controller.extend(ModalFunctionality, {
.then(() => {
this.setProperties({ privateKey: null, publicKey: null });
})
- .catch(popupAjaxError)
+ .catch((error) => {
+ if (!this.privateKey || this.themeCannotBeInstalled) {
+ return popupAjaxError(error);
+ }
+
+ this.set(
+ "themeCannotBeInstalled",
+ I18n.t("admin.customize.theme.force_install")
+ );
+ })
.finally(() => this.set("loading", false));
},
},
diff --git a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs
index 31690ff15f4..4fe285d33eb 100644
--- a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs
+++ b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs
@@ -15,281 +15,312 @@
{{error}}
{{/each}}
- {{#unless this.model.supported}}
-
- {{i18n "admin.customize.theme.required_version.error"}}
- {{#if this.model.remote_theme.minimum_discourse_version}}
- {{i18n "admin.customize.theme.required_version.minimum" version=this.model.remote_theme.minimum_discourse_version}}
- {{/if}}
- {{#if this.model.remote_theme.maximum_discourse_version}}
- {{i18n "admin.customize.theme.required_version.maximum" version=this.model.remote_theme.maximum_discourse_version}}
- {{/if}}
-
- {{/unless}}
-
- {{#unless this.model.enabled}}
-
- {{#if this.model.disabled_by}}
- {{i18n "admin.customize.theme.disabled_by"}}
-
- {{avatar this.model.disabled_by imageSize="tiny"}}
- {{this.model.disabled_by.username}}
-
- {{format-date this.model.disabled_at leaveAgo="true"}}
+ {{#if this.finishInstall}}
+
- {{/unless}}
-
-
-
- {{#if this.showCheckboxes}}
-
- {{#unless this.model.component}}
-
-
- {{/unless}}
- {{#if this.model.remote_theme}}
-
- {{/if}}
- {{/if}}
-
- {{#unless this.model.component}}
-
-
-
- {{i18n "admin.customize.theme.color_scheme"}}
-
-
-
-
-
{{i18n "admin.customize.theme.color_scheme_select"}}
-
-
- {{#if this.colorSchemeChanged}}
-
-
- {{/if}}
-
-
-
- {{/unless}}
-
- {{#if this.parentThemes}}
-
-
{{i18n "admin.customize.theme.component_of"}}
-
- {{#each this.parentThemes as |theme|}}
- - {{theme.name}}
- {{/each}}
-
-
- {{/if}}
-
- {{#if this.model.component}}
-
-
-
-
-
{{else}}
-
-
-
+ {{#unless this.model.supported}}
+
+ {{i18n "admin.customize.theme.required_version.error"}}
+ {{#if this.model.remote_theme.minimum_discourse_version}}
+ {{i18n "admin.customize.theme.required_version.minimum" version=this.model.remote_theme.minimum_discourse_version}}
+ {{/if}}
+ {{#if this.model.remote_theme.maximum_discourse_version}}
+ {{i18n "admin.customize.theme.required_version.maximum" version=this.model.remote_theme.maximum_discourse_version}}
+ {{/if}}
-
- {{/if}}
+ {{/unless}}
- {{#unless this.model.remote_theme.is_git}}
-
-
{{i18n "admin.customize.theme.css_html"}}
- {{#if this.model.hasEditedFields}}
-
{{i18n "admin.customize.theme.custom_sections"}}
-
- {{#each this.editedFieldsFormatted as |field|}}
- - {{field}}
- {{/each}}
-
- {{else}}
-
- {{i18n "admin.customize.theme.edit_css_html_help"}}
-
- {{/if}}
+ {{#unless this.model.enabled}}
+
+ {{#if this.model.disabled_by}}
+ {{i18n "admin.customize.theme.disabled_by"}}
+
+ {{avatar this.model.disabled_by imageSize="tiny"}}
+ {{this.model.disabled_by.username}}
+
+ {{format-date this.model.disabled_at leaveAgo="true"}}
+ {{else}}
+ {{i18n "admin.customize.theme.disabled"}}
+ {{/if}}
+
+
+ {{/unless}}
-
-
-
-
-
{{i18n "admin.customize.theme.uploads"}}
- {{#if this.model.uploads}}
-
- {{#each this.model.uploads as |upload|}}
- -
- ${{upload.name}}: {{upload.filename}}
-
-
-
-
- {{/each}}
-
- {{else}}
-
{{i18n "admin.customize.theme.no_uploads"}}
- {{/if}}
-
-
- {{/unless}}
-
- {{#if this.extraFiles.length}}
-
-
{{i18n "admin.customize.theme.extra_files"}}
-
-
- {{#if this.model.remote_theme}}
- {{i18n "admin.customize.theme.extra_files_remote"}}
+
+
+ {{#if this.showCheckboxes}}
+
+ {{#unless this.model.component}}
+
+
+ {{/unless}}
+ {{#if this.model.remote_theme}}
+
+ {{/if}}
+
+ {{/if}}
+
+ {{#unless this.model.component}}
+
+
+
+ {{i18n "admin.customize.theme.color_scheme"}}
+
+
+
+
+
{{i18n "admin.customize.theme.color_scheme_select"}}
+
+
+ {{#if this.colorSchemeChanged}}
+
+
+ {{/if}}
+
+
+
+ {{/unless}}
+
+ {{#if this.parentThemes}}
+
+
{{i18n "admin.customize.theme.component_of"}}
- {{#each this.extraFiles as |extraFile|}}
- - {{extraFile.name}}
+ {{#each this.parentThemes as |theme|}}
+ - {{theme.name}}
{{/each}}
-
-
- {{/if}}
-
- {{#if this.hasSettings}}
-
-
{{i18n "admin.customize.theme.theme_settings"}}
-
- {{#each this.settings as |setting|}}
-
- {{/each}}
-
-
- {{/if}}
-
- {{#if this.hasTranslations}}
-
-
{{i18n "admin.customize.theme.theme_translations"}}
-
- {{#each this.translations as |translation|}}
-
- {{/each}}
-
-
- {{/if}}
-
-
{{/if}}
{{#if this.model.component}}
- {{#if this.model.enabled}}
-
- {{else}}
-
- {{/if}}
+
+
+
+
+
+ {{else}}
+
+
+
+
+
{{/if}}
-
+ {{#unless this.model.remote_theme.is_git}}
+
+
{{i18n "admin.customize.theme.css_html"}}
+ {{#if this.model.hasEditedFields}}
+
{{i18n "admin.customize.theme.custom_sections"}}
+
+ {{#each this.editedFieldsFormatted as |field|}}
+ - {{field}}
+ {{/each}}
+
+ {{else}}
+
+ {{i18n "admin.customize.theme.edit_css_html_help"}}
+
+ {{/if}}
-
+
+
+
+
+
{{i18n "admin.customize.theme.uploads"}}
+ {{#if this.model.uploads}}
+
+ {{#each this.model.uploads as |upload|}}
+ -
+ ${{upload.name}}: {{upload.filename}}
+
+
+
+
+ {{/each}}
+
+ {{else}}
+
{{i18n "admin.customize.theme.no_uploads"}}
+ {{/if}}
+
+
+ {{/unless}}
+
+ {{#if this.extraFiles.length}}
+
+
{{i18n "admin.customize.theme.extra_files"}}
+
+
+ {{#if this.model.remote_theme}}
+ {{i18n "admin.customize.theme.extra_files_remote"}}
+ {{else}}
+ {{i18n "admin.customize.theme.extra_files_upload"}}
+ {{/if}}
+
+
+ {{#each this.extraFiles as |extraFile|}}
+ - {{extraFile.name}}
+ {{/each}}
+
+
+
+ {{/if}}
+
+ {{#if this.hasSettings}}
+
+
{{i18n "admin.customize.theme.theme_settings"}}
+
+ {{#each this.settings as |setting|}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#if this.hasTranslations}}
+
+
{{i18n "admin.customize.theme.theme_translations"}}
+
+ {{#each this.translations as |translation|}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+
+ {{/if}}
diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs
index 0918c6aee4e..cebb9a6121f 100644
--- a/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs
+++ b/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs
@@ -111,7 +111,12 @@
⚠️ {{this.duplicateRemoteThemeWarning}}
{{/if}}
-
+ {{#if this.themeCannotBeInstalled}}
+
+ ⚠️ {{this.themeCannotBeInstalled}}
+
+ {{/if}}
+
{{/unless}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/themes-test.js b/app/assets/javascripts/discourse/tests/acceptance/themes-test.js
new file mode 100644
index 00000000000..40595569dcc
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/themes-test.js
@@ -0,0 +1,243 @@
+import { click, fillIn, visit } from "@ember/test-helpers";
+import {
+ acceptance,
+ exists,
+ query,
+} from "discourse/tests/helpers/qunit-helpers";
+import I18n from "I18n";
+import { test } from "qunit";
+
+acceptance("Theme", function (needs) {
+ needs.user();
+
+ needs.pretender((server, helper) => {
+ server.get("/admin/themes", () => {
+ return helper.response(200, {
+ themes: [
+ {
+ id: 42,
+ name: "discourse-incomplete-theme",
+ created_at: "2022-01-01T12:00:00.000Z",
+ updated_at: "2022-01-01T12:00:00.000Z",
+ component: false,
+ color_scheme: null,
+ color_scheme_id: null,
+ user_selectable: false,
+ auto_update: true,
+ remote_theme_id: 42,
+ settings: [],
+ supported: true,
+ description: null,
+ enabled: true,
+ user: {
+ id: 1,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png",
+ title: "Tester",
+ },
+ theme_fields: [],
+ child_themes: [],
+ parent_themes: [],
+ remote_theme: {
+ id: 42,
+ remote_url:
+ "git@github.com:discourse/discourse-incomplete-theme.git",
+ remote_version: null,
+ local_version: null,
+ commits_behind: null,
+ branch: null,
+ remote_updated_at: null,
+ updated_at: "2022-01-01T12:00:00.000Z",
+ last_error_text: null,
+ is_git: true,
+ license_url: null,
+ about_url: null,
+ authors: null,
+ theme_version: null,
+ minimum_discourse_version: null,
+ maximum_discourse_version: null,
+ },
+ translations: [],
+ },
+ ],
+ });
+ });
+
+ server.post("/admin/themes/import", (request) => {
+ const data = helper.parsePostData(request.requestBody);
+
+ if (!data.force) {
+ return helper.response(422, {
+ errors: [
+ "Error cloning git repository, access is denied or repository is not found",
+ ],
+ });
+ }
+
+ return helper.response(201, {
+ theme: {
+ id: 42,
+ name: "discourse-inexistent-theme",
+ created_at: "2022-01-01T12:00:00.000Z",
+ updated_at: "2022-01-01T12:00:00.000Z",
+ component: false,
+ color_scheme: null,
+ color_scheme_id: null,
+ user_selectable: false,
+ auto_update: true,
+ remote_theme_id: 42,
+ settings: [],
+ supported: true,
+ description: null,
+ enabled: true,
+ user: {
+ id: 1,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png",
+ },
+ theme_fields: [],
+ child_themes: [],
+ parent_themes: [],
+ remote_theme: {
+ id: 42,
+ remote_url:
+ "git@github.com:discourse/discourse-inexistent-theme.git",
+ remote_version: null,
+ local_version: null,
+ commits_behind: null,
+ branch: null,
+ remote_updated_at: null,
+ updated_at: "2022-01-01T12:00:00.000Z",
+ last_error_text: null,
+ is_git: true,
+ license_url: null,
+ about_url: null,
+ authors: null,
+ theme_version: null,
+ minimum_discourse_version: null,
+ maximum_discourse_version: null,
+ },
+ translations: [],
+ },
+ });
+ });
+
+ server.put("/admin/themes/42", () => {
+ return helper.response(200, {
+ theme: {
+ id: 42,
+ name: "discourse-complete-theme",
+ created_at: "2022-01-01T12:00:00.000Z",
+ updated_at: "2022-01-01T12:00:00.000Z",
+ component: false,
+ color_scheme: null,
+ color_scheme_id: null,
+ user_selectable: false,
+ auto_update: true,
+ remote_theme_id: 42,
+ settings: [],
+ supported: true,
+ description: null,
+ enabled: true,
+ user: {
+ id: 1,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png",
+ },
+ theme_fields: [],
+ child_themes: [],
+ parent_themes: [],
+ remote_theme: {
+ id: 42,
+ remote_url:
+ "git@github.com:discourse-org/discourse-incomplete-theme.git",
+ remote_version: "0000000000000000000000000000000000000000",
+ local_version: "0000000000000000000000000000000000000000",
+ commits_behind: 0,
+ branch: null,
+ remote_updated_at: "2022-01-01T12:00:30.000Z",
+ updated_at: "2022-01-01T12:00:30.000Z",
+ last_error_text: null,
+ is_git: true,
+ license_url: "URL",
+ about_url: "URL",
+ authors: null,
+ theme_version: null,
+ minimum_discourse_version: null,
+ maximum_discourse_version: null,
+ },
+ translations: [],
+ },
+ });
+ });
+ });
+
+ test("can force install themes", async function (assert) {
+ await visit("/admin/customize/themes");
+
+ await click(".themes-list .create-actions button");
+ await click(".install-theme-items #remote");
+ await fillIn(
+ ".install-theme-content .repo input",
+ "git@github.com:discourse/discourse-inexistent-theme.git"
+ );
+ await click(".install-theme-content button.advanced-repo");
+ await click(".install-theme-content .check-private input");
+
+ assert.notOk(
+ exists(".admin-install-theme-modal .modal-footer .install-theme-warning"),
+ "no Git warning is displayed"
+ );
+
+ await click(".admin-install-theme-modal .modal-footer .btn-primary");
+ assert.ok(
+ exists(".admin-install-theme-modal .modal-footer .install-theme-warning"),
+ "Git warning is displayed"
+ );
+
+ await click(".admin-install-theme-modal .modal-footer .btn-danger");
+
+ assert.notOk(
+ exists(".admin-install-theme-modal:visible"),
+ "modal is closed"
+ );
+ });
+
+ test("can continue installation", async function (assert) {
+ await visit("/admin/customize/themes");
+
+ await click(".themes-list-container .themes-list-item");
+ assert.ok(
+ query(".control-unit .status-message").innerText.includes(
+ I18n.t("admin.customize.theme.last_attempt")
+ ),
+ "it says that theme is not completely installed"
+ );
+
+ await click(".control-unit .btn-primary.finish-install");
+
+ assert.equal(
+ query(".show-current-style .title span").innerText,
+ "discourse-complete-theme",
+ "it updates theme title"
+ );
+
+ assert.notOk(
+ query(".metadata.control-unit").innerText.includes(
+ I18n.t("admin.customize.theme.last_attempt")
+ ),
+ "it does not say that theme is not completely installed"
+ );
+
+ assert.notOk(
+ query(".control-unit .btn-primary.finish-install"),
+ "it does not show finish install button"
+ );
+ });
+});
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
index 8109dcf7f48..2c876a8abf7 100644
--- a/app/assets/stylesheets/common/admin/customize.scss
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -27,9 +27,12 @@
.admin-container {
padding: 0;
}
- .error-message {
+ .error-message,
+ .raw-error {
margin-top: 5px;
margin-bottom: 5px;
+ }
+ .error-message {
.fa {
color: var(--danger);
}
diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb
index 428f8ff5795..50749db4777 100644
--- a/app/controllers/admin/themes_controller.rb
+++ b/app/controllers/admin/themes_controller.rb
@@ -104,7 +104,23 @@ class Admin::ThemesController < Admin::AdminController
@theme = RemoteTheme.import_theme(remote, theme_user, private_key: params[:private_key], branch: branch)
render json: @theme, status: :created
rescue RemoteTheme::ImportError => e
- render_json_error e.message
+ if params[:force]
+ theme_name = params[:remote].gsub(/.git$/, "").split("/").last
+
+ remote_theme = RemoteTheme.new
+ remote_theme.private_key = params[:private_key]
+ remote_theme.branch = params[:branch] ? params[:branch] : nil
+ remote_theme.remote_url = params[:remote]
+ remote_theme.save!
+
+ @theme = Theme.new(user_id: theme_user&.id || -1, name: theme_name)
+ @theme.remote_theme = remote_theme
+ @theme.save!
+
+ render json: @theme, status: :created
+ else
+ render_json_error e.message
+ end
end
elsif params[:bundle] || (params[:theme] && THEME_CONTENT_TYPES.include?(params[:theme].content_type))
diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb
index 21f6bef275b..f72f77c1bc7 100644
--- a/app/models/remote_theme.rb
+++ b/app/models/remote_theme.rb
@@ -171,6 +171,13 @@ class RemoteTheme < ActiveRecord::Base
end
end
+ # Update all theme attributes if this is just a placeholder
+ if self.remote_url.present? && !self.local_version && !self.commits_behind
+ self.theme.name = theme_info["name"]
+ self.theme.component = [true, "true"].include?(theme_info["component"])
+ self.theme.child_components = theme_info["components"].presence || []
+ end
+
METADATA_PROPERTIES.each do |property|
self.public_send(:"#{property}=", theme_info[property.to_s])
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 5365a990bc5..3b776812efb 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4719,6 +4719,8 @@ en:
import_web_advanced: "Advanced..."
import_file_tip: ".tar.gz, .zip, or .dcstyle.json file containing theme"
is_private: "Theme is in a private git repository"
+ finish_install: "Finish Theme Installation"
+ last_attempt: "Installation process did not finish, last attempted:"
remote_branch: "Branch name (optional)"
public_key: "Grant the following public key access to the repo:"
public_key_note: "After entering a valid private repository URL above, an SSH key will be generated and displayed here."
@@ -4729,6 +4731,8 @@ en:
install_git_repo: "From a git repository"
install_create: "Create new"
duplicate_remote_theme: "The theme component “%{name}” is already installed, are you sure you want to install another copy?"
+ force_install: "The theme cannot be installed because the Git repository is inaccessible. Are you sure you want to continue installing it?"
+ create_placeholder: "Create Placeholder"
about_theme: "About"
license: "License"
version: "Version:"
diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb
index a3cf8cca71a..e42e67787a0 100644
--- a/spec/requests/admin/themes_controller_spec.rb
+++ b/spec/requests/admin/themes_controller_spec.rb
@@ -151,6 +151,25 @@ RSpec.describe Admin::ThemesController do
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
end
+ it 'can fail if theme is not accessible' do
+ post "/admin/themes/import.json", params: {
+ remote: 'git@github.com:discourse/discourse-inexistent-theme.git'
+ }
+
+ expect(response.status).to eq(422)
+ expect(response.parsed_body["errors"]).to contain_exactly(I18n.t("themes.import_error.git"))
+ end
+
+ it 'can force install theme' do
+ post "/admin/themes/import.json", params: {
+ remote: 'git@github.com:discourse/discourse-inexistent-theme.git',
+ force: true
+ }
+
+ expect(response.status).to eq(201)
+ expect(response.parsed_body["theme"]["name"]).to eq("discourse-inexistent-theme")
+ end
+
it 'fails to import with an error if uploads are not allowed' do
SiteSetting.theme_authorized_extensions = "nothing"